The app module is your API: the server that registers controllers, runs middleware, and answers HTTP. app:create scaffolds everything you need to ship it as a container — a multi-stage Dockerfile, a .dockerignore, a docker-compose.yml for its backing stores, and (optionally) CI/CD pipelines. This page walks through building that image, pushing it to a registry, and running it in production.
The guiding principle is one image, many environments. A single image serves both staging and production; they differ only by the environment variables (APP_ENV, DATABASE_URL, the *_REDIS_URLs, JWT_SECRET, CORS_*, …) injected at deploy time. Never bake secrets or per-environment values into the image.
What app:create generates
| File | Location | Role |
|---|
Dockerfile | modules/app/Dockerfile | Multi-stage Bun build producing a production target. |
.dockerignore | project root | Keeps node_modules, dist, tests, secrets, and CI files out of the build context. |
docker-compose.yml | modules/app/docker-compose.yml | Postgres + Redis for local development. |
| CI/CD pipelines | .github/, .gitlab/, or bitbucket-pipelines.yml | Build → push → deploy, if you opt in during create. |
The Dockerfile
The generated Dockerfile is a multi-stage build on the official oven/bun image. Each stage has one job:
| Stage | Purpose |
|---|
base | Pins the Bun version, sets WORKDIR /app, disables Husky (HUSKY=0). |
deps | Full install (incl. devDependencies), cached on the lockfile. |
prod-deps | Production-only install, in the same base image so prebuilt native addons match the runtime platform. |
build | Runs bun run build to emit dist/index.js with @module/* aliases inlined. |
production | Copies prod node_modules + the compiled bundle + runtime YAML, drops to the non-root bun user, and starts the server. |
The production image is not fully standalone. At startup @talosjs/app reads modules/shared/src/roles.yml from the working directory, and native dependencies load their .node addons from node_modules. That is why the image ships the bundle plus production node_modules plus the runtime data files (roles.yml, modules/shared/.env.yml).
The build context must be the repository root, not modules/app/. The
Dockerfile copies package.json, bun.lock, and modules/shared/… by paths
relative to the repo root.
Build the image
Build the production target from the repository root, pointing at the app module’s Dockerfile:
docker build \
-f modules/app/Dockerfile \
--target production \
-t my-app:latest \
.
Pin the Bun version at build time if you need to override the default:
docker build -f modules/app/Dockerfile --target production \
--build-arg BUN_VERSION=1.3.12 -t my-app:latest .
The production stage:
- listens on
PORT (defaults to 3500 in the image; override it with an env var),
- binds
HOST_NAME=0.0.0.0 so it is reachable outside the container,
- runs as the non-root
bun user,
- ships a
HEALTHCHECK that polls /healthcheck — any HTTP response (200/401/404) counts as healthy; only a refused connection fails.
Smoke-test it locally against your dev Postgres and Redis:
docker run --rm -p 3500:3500 \
-e APP_ENV=staging \
-e HOST_NAME=0.0.0.0 \
-e PORT=3500 \
-e DATABASE_URL="postgresql://talos:talos@host.docker.internal:5432/talos" \
-e CACHE_REDIS_URL="redis://host.docker.internal:6379" \
my-app:latest
Push to a container registry
Tag the image for your registry and push. The generated CI/CD pipelines default to GitHub Container Registry (GHCR), but any OCI registry works the same way — authenticate, tag, push.
GitHub (GHCR)
Docker Hub
AWS ECR
Google Artifact Registry
echo "$GITHUB_TOKEN" | docker login ghcr.io -u <username> --password-stdin
docker tag my-app:latest ghcr.io/<owner>/<repo>:latest
docker push ghcr.io/<owner>/<repo>:latest
The token needs the write:packages scope. This is exactly what the
generated .github/workflows/ci.yml does with docker/login-action and
docker/build-push-action.docker login -u <username>
docker tag my-app:latest <username>/my-app:latest
docker push <username>/my-app:latest
aws ecr get-login-password --region <region> \
| docker login --username AWS --password-stdin <account>.dkr.ecr.<region>.amazonaws.com
docker tag my-app:latest <account>.dkr.ecr.<region>.amazonaws.com/my-app:latest
docker push <account>.dkr.ecr.<region>.amazonaws.com/my-app:latest
Create the repository first with aws ecr create-repository --repository-name my-app.gcloud auth configure-docker <region>-docker.pkg.dev
docker tag my-app:latest <region>-docker.pkg.dev/<project>/<repo>/my-app:latest
docker push <region>-docker.pkg.dev/<project>/<repo>/my-app:latest
Tag by commit SHA (ghcr.io/<owner>/<repo>:sha-abc1234) as the immutable
deploy target and move :latest to track the newest build. The generated
GitHub pipeline does exactly this, so every deploy references a specific,
reproducible image.
Run it in production
The image is stateless; its Postgres and Redis live outside it. Point it at your managed or self-hosted datastores via environment variables. A production Compose file that pulls the pushed image and runs it alongside a .env file works well on a single host:
docker-compose.production.yml
services:
app:
image: ${APP_IMAGE:-ghcr.io/owner/repo:latest}
restart: unless-stopped
ports:
- "80:3500"
env_file: .env
environment:
- HOST_NAME=0.0.0.0
- PORT=3500
# On the host: pull the new image and swap in with zero downtime.
docker login ghcr.io -u <username> --password-stdin <<< "$GITHUB_TOKEN"
docker pull ghcr.io/<owner>/<repo>:latest
# Run migrations before switching traffic, so a bad migration fails early.
docker run --rm --env-file .env ghcr.io/<owner>/<repo>:latest bun run db:migrate
# --wait blocks until the container reports healthy via the Dockerfile HEALTHCHECK.
APP_IMAGE=ghcr.io/<owner>/<repo>:latest docker compose \
-f docker-compose.production.yml up -d --no-deps --wait app
This mirrors the generated production.yml GitHub workflow, which SSHes to the host, runs migrations, performs a health-gated docker compose up --wait, and keeps the previous image tagged :rollback so a failed health check can roll straight back.
The generated modules/app/docker-compose.yml defines Postgres and Redis
only — it is for local development. In production, run Postgres and Redis as
managed services (or a separate, backed-up Compose stack) and give the app
their URLs through the environment. Do not point production at the dev
compose stack.
The same image runs anywhere that runs OCI containers. Pick per your ops appetite:
| Target | Notes |
|---|
| Single VM + Docker Compose | The generated production.yml flow. Simple, SSH-based, with built-in rollback. |
| Kubernetes | Use the image in a Deployment; map the Dockerfile HEALTHCHECK to a liveness/readiness probe on /healthcheck; inject config via Secret/ConfigMap. |
| AWS ECS / Fargate, Google Cloud Run, Azure Container Apps | Managed container runtimes — supply the image URL and environment variables; set the container port to PORT. |
| Fly.io, Render, Railway | Point them at the repo/Dockerfile or the pushed image; set env vars in their dashboards. |
Every per-environment value is an environment variable read through AppEnv. In development they live in modules/shared/.env.yml; in production you inject the same keys through your platform’s secret manager. See Configuration for how the YAML keys flatten to env vars (app.port → PORT, database.url → DATABASE_URL, …).
The essentials to set at deploy time:
| Variable | Purpose |
|---|
APP_ENV | Environment name (staging, production, …). Drives isProduction and friends. |
HOST_NAME | Bind address — must be 0.0.0.0 in a container (the image sets this). |
PORT | Listen port (image default 3500). |
DATABASE_URL | Postgres connection string. |
CACHE_REDIS_URL, PUBSUB_REDIS_URL, RATE_LIMIT_REDIS_URL, QUEUE_REDIS_URL | Redis endpoints for each subsystem. |
JWT_SECRET | Signing secret — rotate away from the scaffold default. |
CORS_* | Cross-origin policy (see below). |
Never commit real .env.yml values. .dockerignore already excludes .env
and .env.* (keeping only .env.yml), but production credentials belong in
your platform’s secret store, injected at runtime — not in the image and not
in git.
CORS
Your API almost always answers a browser front-end (a spa) on another origin, so configure CORS explicitly for each deployed environment. The CorsMiddleware is already wired into the app’s cors slot; its policy comes entirely from CORS_* environment variables:
CORS_ORIGINS=https://app.example.com, https://admin.example.com
CORS_METHODS=GET, POST, PUT, PATCH, DELETE
CORS_HEADERS=Content-Type, Authorization
CORS_CREDENTIALS=true
CORS_MAX_AGE=86400
Use an explicit CORS_ORIGINS allowlist in production — never the wildcard
*. And never combine CORS_CREDENTIALS=true with CORS_ORIGINS=*: browsers
reject a credentialed response whose allow-origin is *. Full details in the
CORS guide.
Migrations and health
- Migrations: run
bun run db:migrate against the target database before routing traffic to the new image, so a broken migration surfaces before the swap. The production pipeline does this in a throwaway container.
- Health checks: the image’s
HEALTHCHECK hits /healthcheck. Point your load balancer, orchestrator probe, or uptime monitor at the same path. Any HTTP response means the process is up.
Deploy with CI/CD
If you answered yes to “Create CI/CD files?” during app:create, you already have a build-and-deploy pipeline for GitHub, GitLab, or Bitbucket. The GitHub setup is two workflows:
ci.yml — lints, tests, then builds and pushes the image to GHCR (tagged by branch, short SHA, and latest on main).
production.yml — triggered by a semver tag (v1.2.3) or manual dispatch; runs CI, then SSHes to the host to pull the image, run migrations, health-gate the swap, and auto-rollback on failure.
Configure the pipeline’s secrets/vars (PROD_HOST, PROD_USER, PROD_SSH_KEY, PRODUCTION_URL) in your provider’s settings, and gate the production environment behind required reviewers for manual approval.
Checklist
- Build the
production target with the repo root as context.
- Push a SHA-tagged, immutable image; move
:latest to follow it.
- Inject every per-environment value (DB, Redis,
JWT_SECRET, CORS_*) at deploy time — never bake them in.
- Run migrations before switching traffic.
- Set
HOST_NAME=0.0.0.0 and expose PORT.
- Lock
CORS_ORIGINS to a real allowlist.
- Wire the orchestrator’s health probe to
/healthcheck.