The image
ghcr.io/archmax-ai/automation is the all-in-one image of archmax automation: one container
runs nginx, which serves the SPA and proxies the API, the API itself and the dispatcher. MongoDB and
S3 are external, and so is Redis when REDIS_URL names one; without it the container runs
its own. The container reaches them through its
environment variables.
The image carries no deployment’s settings: no sign-in settings, no config and no secret. All
of it is read from the container’s environment, or from a mounted config, when the container
starts. The SPA reads its sign-in mode from GET /api/sign-in-settings, which answers from the
API’s own ARCHMAX_SIGN_IN and that mode’s variables, so one image serves every deployment.
Pulling
Section titled “Pulling”docker login ghcr.iodocker pull ghcr.io/archmax-ai/automation:1.5.0docker run --rm -p 8080:8080 --env-file .env ghcr.io/archmax-ai/automation:1.5.0# then open http://localhost:8080| Tag | Pushed by | Names |
|---|---|---|
X.Y.Z, such as 1.5.0 |
every release | that release |
X.Y, such as 1.5 |
every release | the newest release of that minor line |
latest |
every release | the newest release |
unstable |
every push to main that passes CI |
the newest commit on main that passed CI |
unstable-<sha>, such as unstable-3f9c2a1 |
every push to main that passes CI |
that commit (its first seven characters) |
pr-<number>, such as pr-112 |
every push to a pull request from a branch of the repository | that pull request’s newest head |
Every image is built for linux/amd64 and linux/arm64. A release image is built from its
vX.Y.Z tag, and releases come only from main, so X.Y and latest only move forward. An
unstable image is built from a commit on main once CI has passed on it, and unstable only
moves forward too; it is not a release. A pull request from a fork or from Dependabot gets no
image.
Release images are never deleted. The others are kept only while they are useful:
.github/workflows/image-cleanup.yml deletes a pull request’s image when the pull request
closes. Once a day it keeps only the newest ten unstable-<sha> images, besides unstable
itself, and deletes the untagged images that a rebuilt pr-<number> leaves behind. Pin a
release for anything that has to stay. How a release is cut is in
releasing; the releases themselves are listed in
releases.
Labels
Section titled “Labels”Every pushed image carries these OCI labels:
| Label | Value |
|---|---|
org.opencontainers.image.title |
archmax automation |
org.opencontainers.image.source |
The source repository’s URL. |
org.opencontainers.image.version |
The release number, such as 1.5.0, unstable-3f9c2a1 or pr-112. |
org.opencontainers.image.revision |
The commit the image was built from. |
docker inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' \ ghcr.io/archmax-ai/automation:1.5.0The release number
Section titled “The release number”The image carries the release it was built for in APP_VERSION: 1.5.0 for a release,
unstable-3f9c2a1 for an unstable image, pr-112 for a pull request image, and dev for an
image built without the build argument.
GET /api/health answers it:
curl -s https://archmax.example.com/api/health# {"status":"ok","version":"1.5.0"}What runs in the container
Section titled “What runs in the container”| Process | Listens on | Role |
|---|---|---|
| nginx | PORT, default 8080, public |
Serves the built SPA and proxies /api/ to the API. The container’s foreground process: one process, with an empty environment. |
| API | 127.0.0.1:API_PORT, default 3001, internal |
The Hono API. |
| dispatcher | — | Serves every queue: turns, evaluations, design-agent turns, trigger work and environment operations. |
| Redis (Valkey) | 127.0.0.1:6379, internal |
Only when REDIS_URL is unset: the container’s own Redis. It takes a password made at each start, which only the API and the dispatcher receive. |
| workers | — | One per turn and one per call made through a connection, started by the API and the dispatcher and ended when the work ends. Each holds none of the platform’s variables and reaches the platform’s data only through the process that started it. |
When the container starts, the entrypoint renders nginx’s site configuration from PORT,
API_PORT and ACCESS_LOG, prints a line (two when it runs
its own Redis), starts its own Redis when REDIS_URL is unset,
starts the dispatcher and the API in the background, and runs nginx in the foreground:
[entrypoint] nginx :8080 -> api 127.0.0.1:3001 (+ dispatcher), access log: errorsThe API and the dispatcher are supervised: each is started again two seconds after it exits, so one
crash does not take the service down and a crash loop does not pin the CPU. The EXPOSE 8080
line only documents the default; the port nginx binds is whatever PORT holds, so a host that
injects PORT (Railway does) needs no other setting.
All the processes write to the container’s one standard output. The API’s, the dispatcher’s and the
container’s own Redis’s lines are prefixed [api], [dispatcher] and [redis], nginx’s error log
goes to standard error at warn, and nginx’s access log keeps only failed requests unless
ACCESS_LOG says otherwise (see access log).
Single-process mode
Section titled “Single-process mode”A container started with a command runs only that command: the entrypoint hands over to it,
and starts no nginx, no supervisor and no Redis of its own. This is how docker-compose.yml
runs the API and the dispatcher as separate services:
docker run --rm --env-file .env ghcr.io/archmax-ai/automation:1.5.0 \ pnpm --filter @archmax/dispatcher start
docker run --rm -p 3001:3001 --env-file .env ghcr.io/archmax-ai/automation:1.5.0 \ pnpm --filter @archmax/api startIn this mode PORT is the API’s own port again (default 3001), REDIS_URL is required in
production, and the API binds every interface unless API_HOST names one, so the container next
to it can reach it. In the all-in-one container the API binds 127.0.0.1 only, and nginx is its
one client. Restarting a process that exits is the host’s job. The image’s healthcheck stays
defined whatever the command: it probes the API on 127.0.0.1 at API_PORT.
The container’s own Redis
Section titled “The container’s own Redis”Started without a command and with REDIS_URL unset or empty, the container runs a Redis of its
own and points its API and dispatcher at it. It is Valkey, the BSD-licensed Redis fork, whose
licence the image carries at /usr/share/doc/valkey/COPYING. It listens on 127.0.0.1:6379 only
and serves this container alone. It takes a password made at this start, which only the API and
the dispatcher receive, and refuses a client without it. Its memory is capped at
ARCHMAX_EMBEDDED_REDIS_MAXMEMORY (256mb) and it never evicts a key, so a full Redis refuses new
jobs rather than dropping queued ones. It keeps an append-only file in /data/redis, is started
again like the API and the dispatcher when it exits, and on a stop is shut down last, once the
dispatcher and the API have exited. The entrypoint says so in one line:
[entrypoint] REDIS_URL is unset: this container runs its own Redis (Valkey, 127.0.0.1:6379, maxmemory 256mb, a password made at this start), which serves this container's API and dispatcher only. Set REDIS_URL to an external Redis to run more than one container.No other container sees that Redis, so only one such container serves at a time. Its dispatcher runs
jobs and creates its schedulers only while it holds the single-dispatcher lease, a record in MongoDB
it renews every 10 seconds, which lapses 30 seconds after its last renewal and which a graceful
stop releases at once. A dispatcher without the lease tries again every 10 seconds and logs who holds
it once a minute, and meanwhile its container’s /healthz answers 503, so a load balancer sends
it no requests. So the old container is stopped before the new one starts, with a short outage
in between; AWS has the settings for ECS.
What a replacement loses
Section titled “What a replacement loses”The append-only file outlives Redis restarting inside the container, not the container: a new container’s Redis starts empty. When its dispatcher takes the lease, before it runs anything, it settles the records whose jobs were lost and creates the missing schedulers again; the rest is lost:
| Held only in Redis | After a replacement |
|---|---|
| A queued turn or evaluation | Enqueued again. |
| A running turn or evaluation | Ends failed, interrupted by a platform restart. |
| An agent or environment being provisioned | Provisioned again. |
| A queued or running environment operation (publish, restore, discard, promotion) | Ends failed. |
| The schedulers of the sweeps, of schedule triggers and of renewing callback triggers | Created again when the dispatcher starts. |
| A requested trigger reconciliation | Queued again by the retry sweep once it has waited 10 minutes. |
| A trigger firing not yet run: a webhook or callback request already answered, an item a poll already read | Lost. |
| An OAuth2 connect in progress | Lost; the person connects again. |
| A live chat stream | Lost. |
How the processes are kept apart
Section titled “How the processes are kept apart”The container’s processes all run as one user, so the image separates them by other means
(the design behind it is the workers change’s decision 11):
- Execute-only programs. Every ELF program in the image, Node.js, BusyBox, nginx, tini,
Valkey and the binaries under
node_modulesamong them, is owned by root and readable by root alone. The kernel starts a process from a program its user cannot read as non-dumpable, so each process’s/proc/<pid>/environ,memandfdbelong to root, and no process of the container’s user reads another’s, or its own through/proc. The build fails if one of them escaped. - PID 1. The container runtime starts its first program while it can still read a file whose mode denies it, so the image’s entrypoint is a shell that execs tini in its place: PID 1 is non-dumpable too.
- nginx runs as one process (
master_process off): nginx declares the workers a master forks dumpable, which would expose the requests passing through them. It gets an empty environment. - Workers run the JavaScript the build compiled beside each source file
(
packages/core/tools/build-workers.ts) under the Node.js permission model, with an environment built from their own schema’s names. - The container’s own Redis refuses a client without the password made at this start.
Run the container with docker run --cap-drop ALL; the image needs nothing it removes.
Without it, what the runtime starts after PID 1 (the health check, a docker exec) is still
dumpable, holding the container’s environment while it runs. A container run as root reads
every process anyway.
Worker host
Section titled “Worker host”docker run -d -p 7340:7340 -e ARCHMAX_WORKER_HOST_KEY=... \ ghcr.io/archmax-ai/automation:1.5.0 worker-hostThe worker-host command runs only workers, for the API and the dispatcher of a
deployment that names this host in ARCHMAX_WORKER_HOST and shares its key. It listens on
ARCHMAX_WORKER_HOST_PORT (default 7340) for TLS connections authenticated by the key,
starts no nginx, API, dispatcher or Redis, and refuses to start when its environment holds a
data store’s address, a key, the config or a cloud credential (see
the variables).
Environment the image sets
Section titled “Environment the image sets”| Variable | Value |
|---|---|
NODE_ENV |
production |
APP_VERSION |
the APP_VERSION build argument, dev by default |
API_PORT |
3001 |
ARCHMAX_SCRATCH_DIR |
/data/scratch |
ARCHMAX_SKILL_REGISTRIES_DIR |
/data/skill-registries |
ARCHMAX_TEMPLATE_REPO |
/app/.archmax-template |
A container can override any of them. Because NODE_ENV is production, a container refuses to
start without a sign-in mode (ARCHMAX_SIGN_IN=oidc with ARCHMAX_OIDC_ISSUER and
ARCHMAX_OIDC_CLIENT_ID, or ARCHMAX_SIGN_IN=aws-alb with ARCHMAX_AWS_ALB_ARN and
ARCHMAX_AWS_ALB_SIGN_OUT_URL), MASTER_KEY (or ARCHMAX_SECRET_STORE=aws-secrets-manager with
ARCHMAX_AWS_SECRETS_MANAGER_REGION), and a config with a
designAgent section. See the
boot rules.
User and directories
Section titled “User and directories”Every process runs as the base image’s unprivileged user, node, never as root, nginx
included. A platform may start the container as another user instead — Kubernetes’
runAsUser, OpenShift’s arbitrary UIDs, an ECS task’s user, Railway’s RAILWAY_RUN_UID — and
the image runs the same way: nothing writes into the image’s own files at runtime. What the
container writes goes to /tmp/archmax (nginx’s site file, pid and temporary files, the
container’s own Redis’s config, the process logs’ pipes) or to /data, both writable by any user.
So the root filesystem may even be read-only, with /tmp and /data mounted writable.
FROM node:22-alpine3.24 AS runtime# ...RUN mkdir -p /data && chmod 1777 /dataRUN git config --system user.name "archmax" # ...USER node| Path | Holds |
|---|---|
/app |
The app directory: the API’s, the dispatcher’s and the shared packages’ sources and their production dependencies (no build tools, no test runner), config.yaml.example and the agent template .archmax-template; the web app is only its built files, served by nginx. pnpm is included, without npm. Owned by root. The API and the dispatcher run TypeScript directly with tsx, so there is no compile step. |
/app/config.yaml |
Where a mounted config is read from. The image holds none. |
/usr/share/nginx/html |
The built SPA. |
/data/scratch |
The disposable git scratch (ARCHMAX_SCRATCH_DIR), created at start by the user the container runs as. |
/data/skill-registries |
The registry clones (ARCHMAX_SKILL_REGISTRIES_DIR), created the same way and cloned again when the API starts. |
/data/redis |
The container’s own Redis’s append-only file, when it runs one. |
/tmp/archmax |
Everything else the container writes at runtime. |
No directory under /data needs a volume. Every environment file and session file is an object
in S3, so nothing is lost when the container restarts. A volume mounted at /data must be
writable by the user the container runs as; the entrypoint stops with a message naming the
directory otherwise. Git is configured, for every user, to author the commits it makes in
environment repositories as archmax <automation@archmax.ai>, with main as the default branch.
Besides nginx, the image installs git (with ca-certificates for its HTTPS clones), envsubst
(from gettext-envsubst, which renders the nginx configuration), tini, which runs as PID 1 and
hands a stop signal to the entrypoint, and Valkey’s valkey-server and valkey-cli for the
container’s own Redis, with libssl3, the TLS library they load; the healthcheck uses BusyBox’s
wget.
The config
Section titled “The config”The image holds no config. A deployment supplies one when the container
starts, either as ARCHMAX_CONFIG or as a file mounted at /app/config.yaml:
# as one line of JSON, base64-encoded so a templating host leaves the placeholders alonedocker run --rm -p 8080:8080 --env-file .env \ -e ARCHMAX_CONFIG="$(pnpm -s config:env --base64)" \ ghcr.io/archmax-ai/automation:1.5.0
# or as a mounted file, read from the app directorydocker run --rm -p 8080:8080 --env-file .env \ -v "$PWD/config.yaml:/app/config.yaml:ro" \ ghcr.io/archmax-ai/automation:1.5.0The config’s secrets stay environment variables, named in the document by ${{NAME}}
placeholders.
Healthcheck
Section titled “Healthcheck”HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ CMD wget -q -O /dev/null "http://127.0.0.1:${API_PORT:-3001}/api/health" || exit 1The probe goes to the API’s internal port, so it does not depend on PORT. GET /api/health is
answered before the database middleware and never waits on MongoDB. It is liveness; a load
balancer probes readiness at /healthz on the public port instead (see locations).
The entrypoint renders nginx’s site configuration with envsubst, filling in only PORT,
API_PORT and the access-log directive, so nginx’s own variables are left as they are.
Locations
Section titled “Locations”| Location | Behaviour |
|---|---|
/healthz |
Readiness, for a load balancer’s health check: proxied to the API’s /api/health/ready, which answers like /api/health and, when the container runs its own Redis, 503 while its dispatcher does not hold the single-dispatcher lease. With the API down nginx answers 502, never the SPA. |
/api/ |
Proxied to the API over HTTP/1.1, with a 300 s read timeout and no proxy cache. Buffering is off both ways: responses such as event streams reach the browser as they are written, and request bodies reach the API as they arrive. nginx sets Host, X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host (the host the request was sent to) and X-Real-IP, which it takes from the socket, or, from a proxy ARCHMAX_TRUSTED_PROXIES names, from the client address that proxy forwards; never from a header anyone else sent. The API’s rate limits key on it. It drops x-amzn-oidc-accesstoken, the access token AWS ALB sign-in forwards, and passes x-amzn-oidc-data on unchanged. |
/assets/ |
The content-hashed Vite assets. A missing one is a 404, never the SPA. |
/ |
The SPA’s files, falling back to index.html for the client-side routes. |
A request body may be up to 21 MiB, just above the API’s own 20 MiB upload limit, so an
oversized upload sees the API’s 413 rather than nginx’s. The API enforces its own limits on
the stream: 1 MiB for JSON, 256 KiB under /api/webhooks/ and /api/callbacks/, and 20 MiB for
uploads. A request header line may be up to 32 KiB, so a load balancer’s session cookie shards
and signed headers fit. Text, JSON, JavaScript, XML and SVG responses of 256 bytes or more are
gzip-compressed.
Response headers
Section titled “Response headers”| Header | Value |
|---|---|
Cache-Control |
public, max-age=31536000, immutable under /assets/; no-cache for index.html and the other files; under /api/, the API sets its own. |
X-Content-Type-Options |
nosniff |
X-Frame-Options |
DENY; SAMEORIGIN for /silent-callback.html |
Referrer-Policy |
strict-origin-when-cross-origin |
Strict-Transport-Security |
max-age=31536000; includeSubDomains. The image expects to sit behind TLS termination, such as a host’s proxy or a load balancer. |
Content-Security-Policy |
Below. |
Permissions-Policy |
camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=() |
Every header but Cache-Control is sent on every response, errors included. The policy:
default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https:; frame-src 'self' https:; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; worker-src 'self' blob:The SPA runs only its own bundled scripts. Inline styles stay allowed for the component
library’s style attributes. Frames and connections may go to any https: origin for OIDC
sign-in’s hidden frame and the token endpoint on the identity provider’s domain; that frame ends
on /silent-callback.html, the one page the SPA itself may frame (frame-ancestors 'self'
there). Agent-authored evidence HTML renders in a sandboxed srcdoc iframe, which inherits this
policy and so runs no script either.
Access log
Section titled “Access log”ACCESS_LOG decides how much of the HTTP access log reaches standard output:
| Value | Kept |
|---|---|
errors (default) |
Only the requests whose status is not 2xx or 3xx. |
all |
Every request, for debugging a proxy or a client. |
off |
None. |
Any other value is read as errors, with a line on standard error. An open editor polls the API
often enough that the full log buries everything the application says, which is why failed
requests are the default.
Some URLs carry secrets, and the access log never keeps them. Each line logs the request URI with these parts replaced; the request itself is untouched:
| Request URI | Logged as |
|---|---|
/api/webhooks/<token>… |
/api/webhooks/[redacted]… |
/api/callbacks/<token>… |
/api/callbacks/[redacted]… |
/api/agui/<token>… |
/api/agui/[redacted]… |
/api/mcp/<token>… |
/api/mcp/[redacted]… |
/api/invitations/link/<token>… (the consent screen’s read) |
/api/invitations/link/[redacted]… |
/invite/<token>… (the invite page) |
/invite/[redacted]… |
/api/oauth2/callback?<query> |
/api/oauth2/callback?[redacted] |
The Referer a line logs has its token redacted the same way when it names one of the <token>
paths above, such as the invite page a request came from. The OAuth2 callback only redirects, so
it is never a request’s Referer.
docker run --rm -p 8080:8080 --env-file .env -e ACCESS_LOG=all \ ghcr.io/archmax-ai/automation:1.5.0Building the image
Section titled “Building the image”docker build -t archmax . # reports `dev`docker build --build-arg APP_VERSION=1.5.0 -t archmax:1.5.0 . # reports `1.5.0`| Build argument | Default | Meaning |
|---|---|---|
APP_VERSION |
dev |
The release number the image sets as APP_VERSION. Declared after the dependencies are installed and the SPA is built, so a new number reuses those layers. |
VITE_API_BASE_URL |
empty | The API base URL inlined into the SPA. Left empty, the SPA calls its own origin and nginx proxies /api/ to the API. |
Separate stages build what the image takes. One installs the web app’s dependencies, development
ones included, and builds the SPA, once, on the platform doing the build. Another installs, from
the frozen lockfile, only the production dependencies of the API and the dispatcher and of every
workspace package they reach, compiles the code workers run beside its source, and deletes
every other workspace package and the tests. The image
takes those two results and Valkey’s binaries, and none of the build tools: no Vite, no
TypeScript compiler, no test runner and no npm. The build context leaves out every .env file
but .env.example, config.yaml, .git, .github, every node_modules, the documentation
site (apps/docs), openspec, docs, tools, scripts, and everything in docker/ except
nginx.conf and entrypoint.sh. Nothing a deployment differs by is built in.