Skip to main content
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

FileLocationRole
Dockerfilemodules/app/DockerfileMulti-stage Bun build producing a production target.
.dockerignoreproject rootKeeps node_modules, dist, tests, secrets, and CI files out of the build context.
docker-compose.ymlmodules/app/docker-compose.ymlPostgres + Redis for local development.
CI/CD pipelines.github/, .gitlab/, or bitbucket-pipelines.ymlBuild → 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:
StagePurpose
basePins the Bun version, sets WORKDIR /app, disables Husky (HUSKY=0).
depsFull install (incl. devDependencies), cached on the lockfile.
prod-depsProduction-only install, in the same base image so prebuilt native addons match the runtime platform.
buildRuns bun run build to emit dist/index.js with @module/* aliases inlined.
productionCopies 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.
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.
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.

Platform options

The same image runs anywhere that runs OCI containers. Pick per your ops appetite:
TargetNotes
Single VM + Docker ComposeThe generated production.yml flow. Simple, SSH-based, with built-in rollback.
KubernetesUse 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 AppsManaged container runtimes — supply the image URL and environment variables; set the container port to PORT.
Fly.io, Render, RailwayPoint them at the repo/Dockerfile or the pushed image; set env vars in their dashboards.

Configure the environment

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.portPORT, database.urlDATABASE_URL, …). The essentials to set at deploy time:
VariablePurpose
APP_ENVEnvironment name (staging, production, …). Drives isProduction and friends.
HOST_NAMEBind address — must be 0.0.0.0 in a container (the image sets this).
PORTListen port (image default 3500).
DATABASE_URLPostgres connection string.
CACHE_REDIS_URL, PUBSUB_REDIS_URL, RATE_LIMIT_REDIS_URL, QUEUE_REDIS_URLRedis endpoints for each subsystem.
JWT_SECRETSigning 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.