Stage 7 · Master
Phase 19 — Production Deployment
Docker Compose
Wire organization's api and migrate images into docker-compose.yml with real health checks, a migrate-then-serve start order, and pinned digests so a local stack behaves like the deployment pipeline that will eventually replace it.
A Local Stack That Diverges From Production Teaches the Wrong Lessons
docker-compose.yml has run PostgreSQL and Redis since Part 1, but organization itself has always run as a host process during development, started by air for hot reload. Lesson 1 built real images; this lesson wires them into Compose so the local stack exercises the same migrate-then-serve ordering, health checks, and non-root, read-only container constraints Kubernetes will enforce in Lesson 3 — catching a container-only bug on a laptop instead of in staging.
services:
organization-migrate:
build:
context: .
dockerfile: services/organization/Dockerfile
target: migrate
image: ghcr.io/hoa-platform/organization-migrate:local
command: ["-path", "/app/migrations", "-database", "${MIGRATE_DATABASE_URL}", "up"]
depends_on:
postgres:
condition: service_healthy
restart: "no" # a one-shot job must never restart itself into a crash loop
organization:
build:
context: .
dockerfile: services/organization/Dockerfile
target: api
image: ghcr.io/hoa-platform/organization-api:local
read_only: true
user: "65532:65532"
environment:
DATABASE_URL: ${DATABASE_URL}
REDIS_ADDRESS: redis:6379
ORGANIZATION_HTTP_ADDR: :8081
ports:
- "8081:8081"
- "9090:9090" # internal listener: healthz, readyz, metrics
depends_on:
organization-migrate:
condition: service_completed_successfully
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "/app/organization-api", "-healthcheck"] # a distroless image has no curl or wget to shell out to
interval: 10s
timeout: 3s
retries: 3
start_period: 5s
organization-healthcheck:
image: curlimages/curl:8.11.1
depends_on:
organization:
condition: service_healthy
command: ["curl", "-sf", "http://organization:9090/healthz"]
profiles: ["verify"] # disposable — only runs when explicitly requested, never as part of a normal 'up'service_completed_successfully on organization-migrate is what enforces migrate-then-serve ordering — organization will not even attempt to start until the migration container has exited with status 0, and depends_on's default condition (service_started) would not have provided that guarantee.
DATABASE_URL stays a postgres://-scheme string for pgxpool.New in the api service, exactly as everywhere else in this course. Compose's ${VAR} interpolation happens once, at compose-parse time on the host, and — unlike a POSIX shell — it has no syntax for stripping a prefix off a variable's value. So the developer's .env defines a second value, MIGRATE_DATABASE_URL, already written with the pgx5:// scheme golang-migrate's driver requires, and only organization-migrate's command: array references it.
gcr.io/distroless/static-debian12 ships no shell, curl, or wget — a CMD-form healthcheck like curl -f localhost/healthz would fail immediately with 'exec format error' or 'not found'. The binary itself needs a small internal -healthcheck flag (added to cmd/api's main.go below) that performs the same HTTP check in-process and exits 0 or 1, which Docker's healthcheck mechanism can invoke without a shell.
func main() {
healthcheckMode := flag.Bool("healthcheck", false, "perform an in-process liveness check and exit")
flag.Parse()
if *healthcheckMode {
resp, err := http.Get("http://localhost:9090/healthz")
if err != nil || resp.StatusCode != http.StatusOK {
os.Exit(1)
}
os.Exit(0)
}
// ... normal server startup continues below
}The flag dials 9090, not the API port. Observability moved healthz, readyz and metrics onto a separate internal listener, so a healthcheck aimed at 8081 would now get a 404 from the public router and report a healthy container as failing. Reusing the exact route Kubernetes' own probe will use in Lesson 3 is what keeps the two from drifting apart.
Pin Every Image Compose Does Not Build From This Repository
postgres, redis, and the disposable curl healthcheck sidecar are not built from this Dockerfile — they are pulled. Part 1's docker-compose.yml already pins PostgreSQL and Redis by tag; this lesson tightens that to digests for the images this lesson introduces, since a tag like curlimages/curl:8.11.1 can still be repointed by its publisher even though it looks specific.
organization-healthcheck:
image: curlimages/curl:8.11.1@sha256:6f9a56e1a5ffa0e2b0c8ba61aacd8ba4b9f6d18e8b9e88a8a4b93b9c99a67320
# ...Pinning by digest here means this specific sidecar's behavior cannot change under the same tag between one developer's laptop and another's, or between a laptop and CI.
Verify Migrate-Then-Serve, Not Just That 'docker compose up' Exits Zero
Fail the Stack Loudly When a Migration Fails, Never Silently
NAME IMAGE STATUS
compose-postgres-1 postgres:16.4 Up (healthy)
compose-organization-migrate-1 ghcr.io/hoa-platform/organization-migrate:local Exited (1)
compose-organization-1 ghcr.io/hoa-platform/organization-api:local Createdorganization staying in 'Created' — never starting — is the depends_on service_completed_successfully condition working exactly as intended: a failed migration blocks the API from ever running against a schema it does not match, rather than starting against a half-applied database.
Applied exercise
Wire services/gateway-service into docker-compose.yml with the same discipline
services/gateway-service (from Lesson 1's exercise) has no Compose service yet and needs to reach organization by its Compose service name, not localhost.
- Add a gateway service to docker-compose.yml, built from services/gateway-service/Dockerfile, running read-only and non-root.
- Configure gateway's upstream URL for organization using the Compose service name organization, not localhost or a hardcoded IP.
- Add a healthcheck using gateway's own in-process -healthcheck flag, following this lesson's pattern.
- Verify with docker compose up that gateway only becomes healthy after organization itself reports healthy.
Deliverable
An updated docker-compose.yml with a working gateway service, verified end-to-end with a request that traverses gateway to organization.
Completion checks
- gateway's upstream configuration references the Compose service name, never localhost.
- gateway's healthcheck uses an in-process flag, not a shell command a distroless image cannot run.
- A curl through gateway to an organization route succeeds only after both services report healthy.
Why does organization use depends_on with condition: service_completed_successfully on organization-migrate, instead of the default service_started condition?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.