commit 824a5e90651d6d841539f90b24aa6b0ba0c2328e
parent abab20c7e28927bb75b489d139188b82cf578ea9
Author: finwo <finwo@pm.me>
Date: Sat, 19 Sep 2026 16:41:03 +0200
Alpine based images, reaching architectures node does not publish
Diffstat:
5 files changed, 94 insertions(+), 49 deletions(-)
diff --git a/deploy/Dockerfile b/deploy/Dockerfile
@@ -1,29 +1,49 @@
# deploy/Dockerfile - the conductor
#
# Build from the repository root, since the context is the whole project:
-# docker build -f deploy/Dockerfile -t conductor .
+# docker build -f deploy/Dockerfile -t finwo/conductor .
+#
+# Alpine rather than the official node image, because that one is only
+# published for amd64, arm64 and ppc64le, and this is meant to run on
+# riscv64 as well. Alpine 3.23 carries node 24 on every architecture we
+# publish for, so the base is the same everywhere and nothing has to be
+# fetched from a second source at build time.
#
# State lives in /data: the sqlite database, the git mirrors, the log spool
# and, unless object storage is configured, artifacts. Mount a volume there
# or none of it survives a restart.
-FROM node:24-bookworm-slim
+ARG ALPINE_VERSION=3.23
-# git is needed for the mirrors the conductor reads pipelines and source
-# archives out of. Everything else it does is in node.
-RUN apt-get update \
- && apt-get install -y --no-install-recommends ca-certificates git \
- && rm -rf /var/lib/apt/lists/*
+# npm is only needed to resolve dependencies, so it stays in this stage and
+# out of the image that ships.
+FROM alpine:${ALPINE_VERSION} AS deps
+
+RUN apk add --no-cache nodejs npm
WORKDIR /app
-# Dependencies first, so editing source does not reinstall them. mysql2 and
-# pg are optional and only pulled when the database calls for them; both are
-# installed here so one image covers every backend.
+# Dependencies before source, so editing code does not reinstall them.
+# mysql2 and pg are optional and pure javascript; both are installed so a
+# single image covers every database backend.
COPY package.json ./
RUN npm install --omit=dev --no-audit --no-fund \
&& npm cache clean --force
+FROM alpine:${ALPINE_VERSION}
+
+# git is needed for the mirrors the conductor reads pipelines and source
+# archives out of. Everything else it does is node, and node's sqlite is
+# built in, so there is no database client to install.
+RUN apk add --no-cache ca-certificates git nodejs
+
+# Alpine has no unprivileged user to borrow, unlike the node image.
+RUN addgroup -S conductor && adduser -S -G conductor -h /app conductor
+
+WORKDIR /app
+
+COPY --from=deps /app/node_modules ./node_modules
+COPY package.json ./
COPY src/ ./src/
COPY migrations/ ./migrations/
COPY assets/ ./assets/
@@ -38,9 +58,9 @@ ENV NODE_ENV=production \
CONDUCTOR_MIRROR_PATH=/data/mirrors \
CONDUCTOR_LOG_PATH=/data/logs
-RUN mkdir -p /data && chown -R node:node /data
+RUN mkdir -p /data && chown -R conductor:conductor /data
-USER node
+USER conductor
VOLUME ["/data"]
EXPOSE 8080
diff --git a/deploy/Dockerfile.worker b/deploy/Dockerfile.worker
@@ -1,7 +1,13 @@
# deploy/Dockerfile.worker - the worker
#
# Build from the repository root:
-# docker build -f deploy/Dockerfile.worker -t conductor-worker .
+# docker build -f deploy/Dockerfile.worker -t finwo/conductor-worker .
+#
+# Alpine rather than the official node image, for the same reason as the
+# conductor: node is not published for riscv64, and Alpine 3.23 has both
+# node 24 and a docker client on every architecture we publish for. That
+# also means the docker client comes from the package manager rather than
+# a tarball off download.docker.com, which has no riscv64 build at all.
#
# The worker runs each job in its own container, so it needs a docker
# socket. It does not run a daemon of its own: the containers it starts are
@@ -13,33 +19,13 @@
# compose file in deploy/worker does that; if you run this by hand, mount
# the workspace at the path you configure rather than somewhere convenient.
-FROM node:24-bookworm-slim
-
-# Static docker CLI, rather than docker.io, which would drag in a daemon
-# this image has no use for.
-ARG DOCKER_CLI_VERSION=27.3.1
-
-# git is only needed for projects configured to clone rather than download
-# a source archive; tar always is.
-RUN apt-get update \
- && apt-get install -y --no-install-recommends ca-certificates curl git tar \
- && arch="$(uname -m)" \
- && case "${arch}" in \
- x86_64) docker_arch=x86_64 ;; \
- aarch64) docker_arch=aarch64 ;; \
- *) echo "unsupported architecture: ${arch}" >&2; exit 1 ;; \
- esac \
- && curl -fsSL "https://download.docker.com/linux/static/stable/${docker_arch}/docker-${DOCKER_CLI_VERSION}.tgz" \
- | tar -xz -C /usr/local/bin --strip-components=1 docker/docker \
- && docker --version \
- && apt-get purge -y --auto-remove curl \
- && rm -rf /var/lib/apt/lists/*
+ARG ALPINE_VERSION=3.23
-WORKDIR /app
+FROM alpine:${ALPINE_VERSION} AS deps
-# The source is ES modules, and node reparses every file with a warning
-# unless the package says so.
-RUN printf '{\n "name": "conductor-worker",\n "private": true,\n "type": "module"\n}\n' > package.json
+RUN apk add --no-cache nodejs npm
+
+WORKDIR /app
# The worker source needs no npm packages at all. yaml is installed only so
# the configuration file may be YAML as well as JSON; nothing breaks
@@ -47,6 +33,20 @@ RUN printf '{\n "name": "conductor-worker",\n "private": true,\n "type": "mod
RUN npm install --omit=dev --no-audit --no-fund yaml \
&& npm cache clean --force
+FROM alpine:${ALPINE_VERSION}
+
+# docker-cli to start job containers on the host daemon, git for projects
+# configured to clone rather than download an archive, and GNU tar because
+# the busybox one is not a full substitute for reading source archives.
+RUN apk add --no-cache ca-certificates docker-cli git nodejs tar
+
+WORKDIR /app
+
+# The source is ES modules, and node reparses every file with a warning
+# unless the package says so.
+RUN printf '{\n "name": "conductor-worker",\n "private": true,\n "type": "module"\n}\n' > package.json
+
+COPY --from=deps /app/node_modules ./node_modules
COPY src/worker/ ./src/worker/
# Deliberately no CONDUCTOR_WORKER_CONFIG: the worker is configurable by
diff --git a/deploy/docker-compose.build.yml b/deploy/docker-compose.build.yml
@@ -0,0 +1,25 @@
+# deploy/docker-compose.build.yml - build the images instead of pulling
+#
+# The main compose file runs the published images, which is what a
+# deployment wants. Layer this over it to build from the working tree:
+#
+# docker compose -f deploy/docker-compose.yml \
+# -f deploy/docker-compose.build.yml up -d --build
+#
+# The image names stay the same, so nothing else has to change and the
+# locally built image simply shadows the published one.
+
+services:
+ conductor:
+ build:
+ context: ..
+ dockerfile: deploy/Dockerfile
+ image: "${CONDUCTOR_IMAGE:-finwo/conductor:latest}"
+ pull_policy: build
+
+ worker:
+ build:
+ context: ..
+ dockerfile: deploy/Dockerfile.worker
+ image: "${CONDUCTOR_WORKER_IMAGE:-finwo/conductor-worker:latest}"
+ pull_policy: build
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
@@ -1,6 +1,12 @@
# deploy/docker-compose.yml - a conductor and one worker
#
-# docker compose -f deploy/docker-compose.yml up -d --build
+# docker compose -f deploy/docker-compose.yml up -d
+#
+# That pulls the published images. To run your own build of them instead,
+# add the override next to it:
+#
+# docker compose -f deploy/docker-compose.yml \
+# -f deploy/docker-compose.build.yml up -d --build
#
# Out of the box this is sqlite on a volume with artifacts on disk, which
# is a perfectly reasonable way to run it. Postgres and MinIO are behind
@@ -15,10 +21,7 @@ name: conductor
services:
conductor:
- build:
- context: ..
- dockerfile: deploy/Dockerfile
- image: conductor:latest
+ image: "${CONDUCTOR_IMAGE:-finwo/conductor:latest}"
restart: unless-stopped
ports:
- "${CONDUCTOR_PORT:-8080}:8080"
@@ -40,10 +43,7 @@ services:
- conductor-data:/data
worker:
- build:
- context: ..
- dockerfile: deploy/Dockerfile.worker
- image: conductor-worker:latest
+ image: "${CONDUCTOR_WORKER_IMAGE:-finwo/conductor-worker:latest}"
restart: unless-stopped
depends_on:
- conductor
@@ -51,7 +51,7 @@ services:
CONDUCTOR_URL: http://conductor:8080
CONDUCTOR_WORKER_NAME: "${CONDUCTOR_WORKER_NAME:-compose-worker}"
# Mint one with: docker compose exec conductor node src/admin-cli.js token:add compose-worker
- CONDUCTOR_WORKER_TOKEN: "${CONDUCTOR_WORKER_TOKEN:?set CONDUCTOR_WORKER_TOKEN, see deploy/README}"
+ CONDUCTOR_WORKER_TOKEN: "${CONDUCTOR_WORKER_TOKEN:?set CONDUCTOR_WORKER_TOKEN, see docs/deployment.md}"
CONDUCTOR_WORKER_ARCHES: "${CONDUCTOR_WORKER_ARCHES:-}"
CONDUCTOR_WORKER_CONCURRENCY: "${CONDUCTOR_WORKER_CONCURRENCY:-2}"
# The path has to be identical inside and outside this container.
diff --git a/deploy/worker/docker-compose.yml b/deploy/worker/docker-compose.yml
@@ -22,7 +22,7 @@ name: conductor-worker
services:
worker:
- image: "${CONDUCTOR_WORKER_IMAGE:-conductor-worker:latest}"
+ image: "${CONDUCTOR_WORKER_IMAGE:-finwo/conductor-worker:latest}"
restart: unless-stopped
environment:
CONDUCTOR_URL: "${CONDUCTOR_URL:?set CONDUCTOR_URL in .env}"