Guide 9 of 12

Deploying

One image, every role

The Dockerfile builds one binary that serves, works the queue (apps made with --jobs) and moves the schema, so a web container and a job container are always the same artifact, never built from different commits:

FROM golang:1.27-alpine AS builder
...
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/blog .

FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata && adduser -D -u 10001 blog
USER blog

COPY --from=builder /src/config /app/config
COPY --from=builder /out/blog /usr/local/bin/blog
EXPOSE 8080
ENTRYPOINT ["blog"]

The encrypted credentials travel in the image; the keys never do. .dockerignore keeps every *.key out of the build context, so an image cannot carry both halves of the pair. With no arguments the image serves, reading <NAME>_ENV and <NAME>_CREDENTIALS from the environment; migrate is compiled in, so the same image also inspects and rolls back the schema:

docker exec <container> blog migrate status
docker exec <container> blog migrate down --yes

Staging is the base, production is the overlay

config/deploy.yml is staging; a bare kamal deploy targets it:

kamal deploy

Production is reached only through -d production, which overlays config/deploy.production.yml on top:

kamal deploy -d production

Hashes in the overlay deep-merge with staging's; arrays (env.secret, proxy.hosts) replace wholesale rather than appending, which is why production restates proxy.hosts with its own domain but says nothing about env.secret: the list is identical, only the value Kamal substitutes differs.

Three things to fill in before the first deploy

Kamal's healthcheck points at /healthz rather than its own default /up, since this service has no such route and /healthz touches nothing and allocates nothing.

.kamal/secrets

What Kamal substitutes into deploy.yml's secret: lists. Everything else lives in the encrypted credentials; only the decryption key stays on disk, gitignored, copied between machines by hand:

BLOG_MASTER_KEY=$(cat config/credentials/staging.key)
KAMAL_REGISTRY_PASSWORD=$(go tool credentials get -e staging kamal.registry_password)

.kamal/secrets.production is the same shape, reading config/credentials/production.key instead; Kamal picks whichever file matches the destination. make credentials ENV=staging makes the credentials file and its key the first time, before the first deploy to that environment can work; the service refuses to boot without a database URL in it.

Migrate at boot

On by default: the container applies pending migrations before it starts serving, so the database only has to exist, not already match the code. <NAME>_MIGRATE_AT_BOOT=false turns it off, for a deployment that migrates from one place on purpose.

The job role

Apps made with --jobs get a job role in both deploy.yml and the production overlay: the same image, started as blog worker instead of serving. Same host as web until the queue needs one of its own.

The database

Not an accessory by default. The premise is a service next to a Rails app that already runs Postgres, so blog.database_url in the credentials points at that server, as a separate database on it. Uncommenting an accessories.db block in deploy.yml gives the service its own Postgres container instead, resolved by container name on Kamal's network; the schema is still applied by the container itself at boot.