Container images
Every project ships its own Dockerfile at its root and runs on Kubernetes. CI builds each image, pushes to the registry, and GitOps bumps the tag in Helm values (see GitOps).
| app | base | builds | runs as | k8s object | port |
|---|---|---|---|---|---|
api | node:22-alpine | NestJS → dist | node dist/main.js | Deployment + Service + HPA + Ingress (core pool) | 3333 |
app | node:22-alpine | Nuxt → .output | node .output/server/index.mjs | Deployment + Service + Ingress | 3000 |
admin | node:22-alpine | Nuxt → .output | node .output/server/index.mjs | Deployment + Service + Ingress | 3001 |
worker | Playwright/Chromium (heavy) | agent loop + tools | one task, then exits | k8s Job (no Service) — created per task | — |
Common principles
- Multi-stage builds — a
buildstage (full deps + compile) and a slimruntimestage (prod deps + artifacts only). Small final image, fast pulls. - Non-root user; pinned base image tags;
.dockerignore(nonode_modules,.git,dist). - Healthcheck for long-running services (Deployments); k8s
readinessProbe/livenessProbe. - Config via env (12-factor); no secrets baked in — injected by k8s at runtime. Infra creds come from HashiCorp Vault through the External Secrets Operator, not from Sealed Secrets (see GitOps); agent secrets are resolved from Postgres.
- One image per repo dir — with two corrections to the line this used to be.
app/has noDockerfile, thoughhelm/agentfy-appinAgentfy/gitopspins a realghcr.io/agentfy/agentfy-apptag, so that image is built somewhere neither repository records. Andworker/has three:Dockerfile(the shell worker),Dockerfile.browser(the same image plus Chromium, AGNT2-212) andDockerfile.control— a deliberately unhardened image that exists only soscripts/verify-image.shcan be shown failing. The control is never built for deployment.
api — Core + ephemeral brain
# build
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx prisma generate && npm run build
# runtime
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma
USER node
EXPOSE 3333
CMD ["node", "dist/main.js"]The api is a stateless, horizontally-scaled Deployment (HPA scales replicas by load). "Ephemeral agent = api" means no per-agent pod — each turn is a stateless request that loads agent state from Postgres and returns; the shared api fleet scales by traffic. (The literal per-task pod is the worker Job.) Prisma migrations are not in the image — they were planned as a GitOps PreSync hook, and today they run as an initContainer on the api Deployment (helm/agentfy-api/templates/deployment.yaml in Agentfy/gitops).
app / admin — Nuxt
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # → .output (Nitro server build)
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/.output ./.output
USER node
EXPOSE 3000 # admin: 3001
CMD ["node", ".output/server/index.mjs"]SSR (Nitro Node server) behind Ingress. If a page set is fully static, an alternative is nuxt generate → an nginx:alpine image serving the static output — same Deployment shape.
worker — the agent's hands (heavy image)
Different from the others: a heavy base with a real OS — bash, filesystem, and Chromium (Playwright) — plus the agent loop + tool-executors + a WS client. It does not run as a long-lived server; the image runs one task and exits, used by a k8s Job created per task.
# Heavy base with Chromium + deps already present (Playwright image, or
# oven/bun:alpine + chromium as in cleanslice/runtime's Dockerfile).
FROM mcr.microsoft.com/playwright:v1.49.0-jammy
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
USER pwuser
# Connects to Core over WS using SESSION_ID/CORE_WS_URL/SESSION_TOKEN, pulls the
# task, runs bash/fs/browser tools, uploads outputs, then exits.
ENTRYPOINT ["node", "dist/worker.js"]- Borrows the agent loop + tool-executors from
cleanslice/runtime. - Runs in a Job:
restartPolicy: Never,ttlSecondsAfterFinished,emptyDirworkspace, read-only root fs (writable/workspaceonly), short-lived projected secret. See Worker on Kubernetes. - Not GitOps-managed as a Deployment — Argo only manages the worker's image ref + RBAC + NetworkPolicy template +
RuntimeProfilepresets; the Job instances are created at runtime.
Build & deliver flow
git push → docker build <app>/Dockerfile → push ghcr.io/agentfy/<app>:<sha>
→ bump image.tag in Agentfy/gitops helm/<chart>/values.dev.yaml → ArgoCD syncsThe second half is real and the path is exact. The first half is not verified: no workflow in either repository builds or pushes an application image, and this repository has no .github/ at all — yet the values files carry real tags. See Two repositories.
Local dev uses the same Dockerfiles via compose; prod runs them on Hetzner k8s. The single artifact (the image) is what moves through environments — reproducible and revertable.
One exception to the <app>/ context (AGNT2-248). The api's image is built from the repository ROOT — docker build -f api/Dockerfile . — because the catalogue of worker tools it shows a model before any pod exists is generated from worker/src at build time. The worker's source is an input to that build and appears in no layer of the finished image; .dockerignore at the root is what keeps the context from being every node_modules in the repository.