Skip to content
archmax.ai

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.

Terminal window
docker login ghcr.io
docker pull ghcr.io/archmax-ai/automation:1.5.0
docker 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.

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.
Terminal window
docker inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' \
ghcr.io/archmax-ai/automation:1.5.0

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:

Terminal window
curl -s https://archmax.example.com/api/health
# {"status":"ok","version":"1.5.0"}
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: errors

The 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).

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:

Terminal window
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 start

In 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.

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.

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.

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_modules among 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, mem and fd belong 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.

Terminal window
docker run -d -p 7340:7340 -e ARCHMAX_WORKER_HOST_KEY=... \
ghcr.io/archmax-ai/automation:1.5.0 worker-host

The 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).

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.

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 /data
RUN 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 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:

Terminal window
# as one line of JSON, base64-encoded so a templating host leaves the placeholders alone
docker 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 directory
docker run --rm -p 8080:8080 --env-file .env \
-v "$PWD/config.yaml:/app/config.yaml:ro" \
ghcr.io/archmax-ai/automation:1.5.0

The config’s secrets stay environment variables, named in the document by ${{NAME}} placeholders.

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 1

The 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.

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.

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 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.

Terminal window
docker run --rm -p 8080:8080 --env-file .env -e ACCESS_LOG=all \
ghcr.io/archmax-ai/automation:1.5.0
Terminal window
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.