Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/publish-images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ jobs:
dir: webapp-base
- image: bozemanpass/nextjs-base
dir: nextjs-base
- image: bozemanpass/node-service-base
dir: node-service-base
steps:
- name: Checkout
uses: actions/checkout@v5
Expand Down
53 changes: 49 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,66 @@
# stack-wrapper-webapp

Container wrapper schemes for the [stack](https://github.com/bozemanpass/stack) tool
that build and serve node.js webapps, without the app needing to provide its own
that build and run node.js applications, without the app needing to provide its own
container build:

- `webapp-base/` — generic node.js webapp (React, vite, static site generators, etc.)
- `nextjs-base/` — Next.js webapp, with runtime (rather than build-time) environment
variable support
- `node-service-base/` — long-running node.js service (Express, Fastify, etc.)

```
$ stack fetch repo bozemanpass/stack-wrapper-webapp
$ stack webapp build --source-repo ~/my-webapp
```

The wrapper is auto-detected from the app source (a `next` dependency in `package.json`
selects `nextjs`); select explicitly with `--wrapper webapp` or `--wrapper nextjs`.
selects `nextjs`, an `express` dependency selects `node-service`); select explicitly with
`--wrapper webapp`, `--wrapper nextjs` or `--wrapper node-service`.

Each wrapper directory contains a `wrapper.yml` manifest (see the stack tool's
`docs/wrappers.md`), the base image `Containerfile`, the app-image `Containerfile.webapp`,
the `build.sh` build script, and the runtime scripts baked into the base image.
`docs/wrappers.md`), the base image `Containerfile`, the app-image containerfile named by
the manifest, the `build.sh` build script, and the runtime scripts baked into the base
image.

## Webapps vs services

The first two wrappers produce *static content*: the app is built, its output directory
(`dist`, `build`, `.next`) becomes the image payload, and a web server in the base image
serves it. Because the result runs in a browser, which has no environment of its own,
build-time placeholders are rewritten at container start so one image can be deployed to
many environments.

`node-service` is different in kind. The application process *is* the server, so the
build keeps `package.json` and the installed `node_modules` alongside any compiled
output, and the container start command runs the app. Nothing is substituted at startup:
a node process reads `process.env` directly, so ordinary container environment variables
already do the job.

### node-service specifics

The build runs the package manager's install (`npm ci` when a `package-lock.json` is
present), then `npm run build` **only if** `package.json` declares a `build` script — a
plain JavaScript service with no compile step needs no configuration. `devDependencies`
are pruned afterwards, so a TypeScript service gets `tsc` at build time but ships without
it.

At start, the wrapper runs `npm start` if the package declares one, otherwise `node`
against the package's `main` entry point.

`PORT` is set to the wrapper's port (3000), which most node servers already honor.
Applications reading something else can be pointed at it explicitly.

| Variable | Effect |
|----------|--------|
| `STACK_START_COMMAND` | Run this instead of `npm start` / `main` |
| `STACK_LISTEN_PORT` | Port to serve on; exported as `PORT` (default 3000) |
| `STACK_BUILD_TOOL` | Force `npm` / `yarn` / `pnpm` / `bun` instead of detecting from the lockfile |
| `STACK_BUILD_TOOL_INSTALL_SUBCOMMAND` | Override the install command |
| `STACK_BUILD_TOOL_BUILD_SUBCOMMAND` | Override the build command |
| `STACK_SKIP_PRUNE` | Set `true` to keep `devDependencies` in the final image |
| `STACK_SERVICE_DIR` | Where the app lives in the image (default `/app`) |

A service needing something the above cannot express can provide its own
`service-build.sh` in the source root, which the wrapper runs in place of the whole
install/build/prune sequence.
46 changes: 46 additions & 0 deletions node-service-base/Containerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Base image for the 'node-service' wrapper: a long-running node.js service.
#
# Unlike webapp-base, nothing here serves static files -- the application process
# itself is the server, so the base carries only a node runtime, the package
# managers needed to install and build, and the wrapper's scripts.
ARG VARIANT=22-bookworm-slim
FROM node:${VARIANT}

ARG USERNAME=node
ARG NPM_GLOBAL=/usr/local/share/npm-global

# Add NPM global to PATH.
ENV PATH=${NPM_GLOBAL}/bin:${PATH}
# Prevents npm from printing version warnings
ENV NPM_CONFIG_UPDATE_NOTIFIER=false

RUN \
# Configure global npm install location, use group to adapt to UID/GID changes
if ! cat /etc/group | grep -e "^npm:" > /dev/null 2>&1; then groupadd -r npm; fi \
&& usermod -a -G npm ${USERNAME} \
&& umask 0002 \
&& mkdir -p ${NPM_GLOBAL} \
&& touch /usr/local/etc/npmrc \
&& chown ${USERNAME}:npm ${NPM_GLOBAL} /usr/local/etc/npmrc \
&& chmod g+s ${NPM_GLOBAL} \
&& npm config -g set prefix ${NPM_GLOBAL} \
&& su ${USERNAME} -c "npm config -g set prefix ${NPM_GLOBAL}" \
# Install pnpm
&& su ${USERNAME} -c "umask 0002 && npm install -g pnpm" \
# Install bun
&& su ${USERNAME} -c "umask 0002 && npm install -g bun@1.1.x" \
&& npm cache clean --force > /dev/null 2>&1

# jq is used to inspect package.json; procps for signal handling/debugging.
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends jq procps \
&& rm -rf /var/lib/apt/lists/*

COPY scripts /scripts

# The port the service is expected to listen on. Apps that read PORT (the node
# convention) need no change; others can be pointed at STACK_LISTEN_PORT.
ENV PORT=3000
EXPOSE 3000

ENTRYPOINT ["/scripts/start-service.sh"]
17 changes: 17 additions & 0 deletions node-service-base/Containerfile.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Wraps a node.js service's source into a runnable image.
#
# Two stages so that the build context's cruft (.git, CI config) and any
# devDependencies left behind by a partial prune do not reach the final image.
FROM bozemanpass/node-service-base:stack AS builder

ARG STACK_BUILD_TOOL
ARG STACK_SKIP_PRUNE

WORKDIR /app
COPY . .
RUN rm -rf node_modules .git .github
RUN /scripts/build-app.sh /app

FROM bozemanpass/node-service-base:stack
WORKDIR /app
COPY --from=builder /app /app
35 changes: 35 additions & 0 deletions node-service-base/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Build bozemanpass/node-service-base

source ${STACK_CONTAINER_BASE_DIR}/build-base.sh

# See: https://stackoverflow.com/a/246128/1701505
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )

STACK_CONTAINER_BUILD_WORK_DIR=${STACK_CONTAINER_BUILD_WORK_DIR:-$SCRIPT_DIR}
STACK_CONTAINER_BUILD_CONTAINERFILE=${STACK_CONTAINER_BUILD_CONTAINERFILE:-$SCRIPT_DIR/Containerfile}
STACK_CONTAINER_BUILD_TAG=${STACK_CONTAINER_BUILD_TAG:-bozemanpass/node-service-base:stack}

docker build -t $STACK_CONTAINER_BUILD_TAG ${build_command_args} -f $STACK_CONTAINER_BUILD_CONTAINERFILE $STACK_CONTAINER_BUILD_WORK_DIR
rc=$?

if [ $rc -ne 0 ]; then
echo "BUILD FAILED" 1>&2
exit $rc
fi

if [ "$STACK_CONTAINER_BUILD_TAG" != "bozemanpass/node-service-base:stack" ]; then
cat <<EOF

#################################################################

Built host container for $STACK_CONTAINER_BUILD_WORK_DIR with tag:

$STACK_CONTAINER_BUILD_TAG

To test locally run:

stack webapp run --image $STACK_CONTAINER_BUILD_TAG --config-file /path/to/environment.env

EOF
fi
96 changes: 96 additions & 0 deletions node-service-base/scripts/build-app.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/bin/bash
# Install dependencies and build a node.js service in place.
#
# Unlike the webapp wrapper, the result is not a directory of static files to be
# extracted -- the built application and its runtime dependencies stay in WORK_DIR
# and are run by start-service.sh.

SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )

if [ -n "$STACK_SCRIPT_DEBUG" ]; then
set -x
fi

STACK_BUILD_TOOL="${STACK_BUILD_TOOL}"

WORK_DIR="${1:-/app}"

cd "${WORK_DIR}" || exit 1

# An app that needs something unusual can supply its own build script.
if [ -f "${WORK_DIR}/service-build.sh" ]; then
echo "Building service with ${WORK_DIR}/service-build.sh ..."
./service-build.sh || exit 1
exit 0
fi

if [ ! -f "${WORK_DIR}/package.json" ]; then
echo "ERROR: no package.json in ${WORK_DIR} -- nothing to build." 1>&2
echo " A node service must be an npm package." 1>&2
exit 1
fi

if [ -z "$STACK_BUILD_TOOL" ]; then
if [ -f "pnpm-lock.yaml" ]; then
STACK_BUILD_TOOL=pnpm
elif [ -f "yarn.lock" ]; then
STACK_BUILD_TOOL=yarn
elif [ -f "bun.lockb" ]; then
STACK_BUILD_TOOL=bun
else
STACK_BUILD_TOOL=npm
fi
fi
echo "Building package.json based service with ${STACK_BUILD_TOOL} ..."

# Install *everything* -- a TypeScript service needs its devDependencies (tsc and
# friends) to build. They are pruned again further down.
if [ -n "${STACK_BUILD_TOOL_INSTALL_SUBCOMMAND}" ]; then
INSTALL_CMD="${STACK_BUILD_TOOL_INSTALL_SUBCOMMAND}"
elif [ "$STACK_BUILD_TOOL" == "npm" ] && [ -f "package-lock.json" ]; then
# 'ci' is reproducible, and errors out when the lockfile is stale rather than
# silently resolving something else.
INSTALL_CMD="ci"
else
INSTALL_CMD="install"
fi

time $STACK_BUILD_TOOL $INSTALL_CMD || exit 1

# Only build if there is something to build; plain JS services have no build step.
STACK_BUILD_TOOL_BUILD_SUBCOMMAND="${STACK_BUILD_TOOL_BUILD_SUBCOMMAND}"
if [ -z "${STACK_BUILD_TOOL_BUILD_SUBCOMMAND}" ]; then
if jq -e '.scripts.build' package.json >/dev/null 2>&1; then
if [ "$STACK_BUILD_TOOL" == "npm" ]; then
STACK_BUILD_TOOL_BUILD_SUBCOMMAND="run build"
else
STACK_BUILD_TOOL_BUILD_SUBCOMMAND="build"
fi
else
echo "No 'build' script in package.json -- skipping build step."
fi
fi

if [ -n "${STACK_BUILD_TOOL_BUILD_SUBCOMMAND}" ]; then
time $STACK_BUILD_TOOL $STACK_BUILD_TOOL_BUILD_SUBCOMMAND || exit 1
fi

# Drop devDependencies now that the build is done. Set STACK_SKIP_PRUNE=true if the
# app resolves something at runtime that it declares as a devDependency.
if [ "true" != "${STACK_SKIP_PRUNE,,}" ]; then
case "$STACK_BUILD_TOOL" in
npm)
echo "Pruning devDependencies ..."
npm prune --omit=dev || exit 1
;;
pnpm)
echo "Pruning devDependencies ..."
pnpm prune --prod || exit 1
;;
*)
echo "Not pruning devDependencies: no supported prune for ${STACK_BUILD_TOOL}."
;;
esac
fi

exit 0
59 changes: 59 additions & 0 deletions node-service-base/scripts/start-service.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Run the wrapped node.js service.
#
# No runtime environment substitution happens here: unlike a browser bundle, a node
# service reads process.env directly at startup, so ordinary container environment
# variables just work.
if [ -n "$STACK_SCRIPT_DEBUG" ]; then
set -x
fi

STACK_SERVICE_DIR="${STACK_SERVICE_DIR:-/app}"
STACK_LISTEN_PORT="${STACK_LISTEN_PORT:-${PORT:-3000}}"

# Export both spellings: PORT is the node convention, STACK_LISTEN_PORT is the stack one.
export PORT="${STACK_LISTEN_PORT}"
export STACK_LISTEN_PORT

cd "${STACK_SERVICE_DIR}" || exit 1

if [ -f ".env" ]; then
set -a
source .env
set +a
fi

STACK_BUILD_TOOL="${STACK_BUILD_TOOL}"
if [ -z "$STACK_BUILD_TOOL" ]; then
if [ -f "pnpm-lock.yaml" ]; then
STACK_BUILD_TOOL=pnpm
elif [ -f "yarn.lock" ]; then
STACK_BUILD_TOOL=yarn
elif [ -f "bun.lockb" ]; then
STACK_BUILD_TOOL=bun
else
STACK_BUILD_TOOL=npm
fi
fi

# An explicit command wins; otherwise prefer the package's own start script, and
# fall back to its declared entry point.
if [ -n "${STACK_START_COMMAND}" ]; then
echo "Starting service on port ${PORT}: ${STACK_START_COMMAND}"
# Via bash -c so that quoting in the command string is honored rather than being
# flattened by word splitting. bash execs a simple command in place, so the
# service still receives signals directly.
exec bash -c "${STACK_START_COMMAND}"
elif jq -e '.scripts.start' package.json >/dev/null 2>&1; then
echo "Starting service on port ${PORT}: ${STACK_BUILD_TOOL} start"
exec $STACK_BUILD_TOOL start
else
MAIN=$(jq -r '.main // empty' package.json 2>/dev/null)
if [ -n "${MAIN}" ] && [ -f "${MAIN}" ]; then
echo "Starting service on port ${PORT}: node ${MAIN}"
exec node "${MAIN}"
fi
echo "ERROR: don't know how to start this service." 1>&2
echo " Add a 'start' script to package.json, or set STACK_START_COMMAND." 1>&2
exit 1
fi
13 changes: 13 additions & 0 deletions node-service-base/wrapper.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
wrapper:
# Short name, used to select the wrapper.
name: node-service
description: Long-running node.js service (Express, Fastify, etc.)
base-container: bozemanpass/node-service-base
containerfile: Containerfile.service
# Node's conventional port. The base image exports PORT (and STACK_LISTEN_PORT)
# so an app that honors either binds here without any app-side change.
port: 3000
# An app that depends on express is a service, not a webapp. Apps that use a
# different framework should name the wrapper explicitly.
detect:
package-json-dependency: express