Deploy with Docker Compose
We’ve built every piece of Snip in isolation: the axum API, Postgres, Redis, the worker, nginx. Now we wire them into one runnable stack you can bring up with a single command — and scale the app to three replicas while you watch. This is the chapter where “it compiles” becomes “it runs.”
WHY Compose? Running five processes by hand — start Postgres, wait for it, start Redis, start the app, start the worker, configure nginx — is tedious and error-prone. Docker Compose declares the whole topology in one file: what images to use, how they connect, what order to start in, and how to scale. One file, one command, reproducible on any machine with Docker.
The Dockerfile: build big, ship small
Section titled “The Dockerfile: build big, ship small”The biggest mistake with Rust containers is shipping the whole build toolchain. The rust image is
well over a gigabyte — compiler, cargo, dependencies, source. None of that is needed to run the
binary. The fix is a multi-stage build: compile in a fat image, then copy just the binaries into a
tiny one. Here’s the project’s Dockerfile, verbatim:
# ---- build stage ----FROM rust:1-bookworm AS builderWORKDIR /appCOPY Cargo.toml Cargo.lock* ./COPY src ./srcCOPY migrations ./migrationsRUN cargo build --release --bin snip --bin worker
# ---- runtime stage ----FROM debian:bookworm-slimRUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*WORKDIR /appCOPY --from=builder /app/target/release/snip /usr/local/bin/snipCOPY --from=builder /app/target/release/worker /usr/local/bin/workerEXPOSE 8080CMD ["snip"]Walking it:
FROM rust:1-bookworm AS builder— the build stage, namedbuilder. It has the full Rust toolchain. TheASname is how the second stage refers back to it.COPYthencargo build --release— copy in the manifests, source, and migrations, then compile both binaries (--bin snip --bin worker) in--releasemode (optimized, slower to build, much faster to run). One image carries both binaries — that’s what lets the worker reuse it later.FROM debian:bookworm-slim— a fresh, slim runtime stage. No Rust toolchain. We add onlyca-certificates(so outbound TLS works) and clean up apt’s lists in the same layer to keep it small.COPY --from=builder ...— the magic line. We copy only the two compiled binaries out of the builder. The compiler, cargo cache, and source never make it into the final image.CMD ["snip"]— the default command runs the API. (The worker overrides this in Compose — see below.)
:::tip Why multi-stage keeps images small
The final image contains debian-slim + two static-ish binaries + ca-certificates — tens of megabytes
instead of well over a gigabyte. Smaller images pull faster, start faster, and have a smaller attack
surface (fewer packages = fewer CVEs). The build stage is discarded; only what you COPY --from survives.
:::
docker-compose.yml, service by service
Section titled “docker-compose.yml, service by service”Here’s the full Compose file from the project, verbatim:
services: postgres: image: postgres:16-alpine environment: POSTGRES_USER: snip POSTGRES_PASSWORD: snip POSTGRES_DB: snip healthcheck: test: ["CMD-SHELL", "pg_isready -U snip"] interval: 2s timeout: 2s retries: 20
redis: image: redis:7-alpine
# The stateless app. Scale it with: docker compose up --scale app=3 app: build: . environment: DATABASE_URL: postgres://snip:snip@postgres:5432/snip REDIS_URL: redis://redis:6379 BIND_ADDR: 0.0.0.0:8080 BASE_URL: http://localhost:8080 depends_on: postgres: condition: service_healthy redis: condition: service_started
# The analytics worker draining the click queue. worker: build: . command: ["worker"] environment: DATABASE_URL: postgres://snip:snip@postgres:5432/snip REDIS_URL: redis://redis:6379 depends_on: postgres: condition: service_healthy redis: condition: service_started
# nginx load-balances across however many app replicas are running. nginx: image: nginx:alpine ports: - "8080:80" volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro depends_on: - apppostgres — stateful, with a healthcheck
Section titled “postgres — stateful, with a healthcheck”Uses the official postgres:16-alpine image, configured via env vars to create the snip user,
password, and database. The interesting part is the healthcheck: every 2 seconds it runs
pg_isready -U snip, retrying up to 20 times. Postgres takes a moment to accept connections after the
container starts — the healthcheck is how Compose knows when it’s actually ready, not just running.
redis — the simplest service
Section titled “redis — the simplest service”Just redis:7-alpine, no config. It’s the cache and the click queue. Snip treats it as ephemeral, so
there’s no volume or healthcheck — if it restarts, the cache rebuilds on demand and the worst case is a
few lost click events (acceptable, as discussed in the queue chapter).
app — the stateless API
Section titled “app — the stateless API”build: .— unlike Postgres and Redis (pre-built images), the app is built from our Dockerfile in the current directory.environment— the connection strings point at the other services by name (postgres,redis). Compose gives every service a DNS name on a shared network, sopostgres:5432just resolves.depends_onwithcondition: service_healthy— this is why the healthcheck mattered. The app won’t start until Postgres reports healthy (not merely started). Redis only needsservice_started, since the app tolerates a cold cache. This ordering prevents the classic “connection refused on boot” race.
Because the app is stateless (all state lives in Postgres and Redis — the work of the Scaling Out chapter), we can run many copies of it. That’s what comes next.
worker — same image, different command
Section titled “worker — same image, different command”The worker is the payoff of building both binaries into one image:
worker: build: . command: ["worker"]Same build: ., same image — but command: ["worker"] overrides the Dockerfile’s
CMD ["snip"], so this container runs the worker binary instead of the API. One image, two roles. It
drains the Redis click queue into Postgres (see Async Work with a Queue),
so it needs Postgres healthy and Redis started, but it publishes no ports — nothing connects to it.
nginx — the front door
Section titled “nginx — the front door”nginx:alpine mounts our nginx.conf read-only (:ro) and is the only service that publishes a
port: "8080:80" maps your machine’s 8080 to nginx’s 80. Everything you curl goes through nginx,
which load-balances across however many app replicas exist. The app containers themselves are not
published — they’re reachable only inside the Compose network, via nginx. That’s a clean, realistic
front-door topology.
Bring it up — and scale it
Section titled “Bring it up — and scale it”docker compose up --build # postgres + redis + app + worker + nginxdocker compose up --build --scale app=3 # ...with 3 app replicas behind nginx--build rebuilds the image if the Dockerfile or source changed. --scale app=3 is the headline move:
Compose starts three app containers, all sharing the same config, all behind nginx. Because the app
is stateless, nginx can spray requests across all three and any one can serve any request. This is
horizontal scaling you can run on a laptop.
:::note Why scaling “just works” here
Three things had to be true for --scale app=3 to be safe, and earlier chapters did the work: state
lives in Postgres/Redis (stateless app), the rate limiter is shared via Redis (not per-process), and
click counts go through the queue (no contention on the hot path). Scaling is the reward for that
architecture — see Deployment Strategies for how this idea
extends to rolling and blue-green deploys in production.
:::
Try it
Section titled “Try it”# Build everything and run 3 app replicas behind nginx:docker compose up --build --scale app=3
# In another terminal, exercise the API THROUGH nginx (port 8080):curl -s -X POST localhost:8080/shorten -H 'content-type: application/json' \ -d '{"url":"https://example.com"}'# → {"code":"...","short_url":"http://localhost:8080/..."}
curl -sI localhost:8080/<code> # follow it (307)curl -s localhost:8080/metrics # counters
# Now run the load test against the load-balanced stack:cargo run --release --bin loadtest -- http://localhost:8080/health 5000 100# → throughput + p50/p95/p99, now spread across 3 replicasCompare the load-test numbers here against the single-instance run from the
Load Testing chapter (same caveat as there: raise the rate limiter’s
LIMIT before a big run, or most responses will be fast 429s). More replicas should lift throughput on
the read-heavy paths — the whole point of all this scaffolding, now measurable.
Check your understanding
Section titled “Check your understanding”- What problem does a multi-stage Dockerfile solve, and which line copies the result of the build stage into the runtime stage?
- The
workerservice usesbuild: .just likeapp. How does it end up running a different program? - Why does the
appservice wait forcondition: service_healthyon Postgres but onlycondition: service_startedon Redis? - Only one service publishes a host port. Which one, and why is it correct that the
appcontainers don’t publish ports of their own? - What earlier architectural choices make
docker compose up --scale app=3safe to run?
Show answers
- It solves shipping the whole build toolchain in the runtime image: the
rustimage is well over a gigabyte, but none of the compiler, cargo, or source is needed to run the binary. You compile in a fatbuilderstage and thenCOPY --from=builder /app/target/release/snip /usr/local/bin/snip(and the same forworker) into a slimdebian:bookworm-slimstage — thatCOPY --from=builder ...line is what carries the build result across, leaving a tens-of-megabytes image instead of a gigabyte-plus one. - Both binaries (
snipandworker) are baked into the one image bycargo build --bin snip --bin worker. The Dockerfile’sCMD ["snip"]runs the API by default, but the worker service overrides it withcommand: ["worker"], so the same image runs a different program. One image, two roles. - The app requires Postgres to be actually accepting connections before it starts, or it hits the classic “connection refused on boot” race — so it waits on the
pg_isreadyhealthcheck reporting healthy, not merely started. Redis only needsservice_startedbecause the app tolerates a cold cache (it degrades to reading Postgres), so there’s nothing to wait for. - nginx publishes
"8080:80"— it’s the front door. Theappcontainers correctly don’t publish ports because clients should reach them only through nginx, which load-balances across however many replicas exist; exposing the replicas directly would bypass the load balancer and break the clean front-door topology. - The same choices that made Scaling Out work: the app is stateless (all state lives in Postgres and Redis), the rate limiter is shared via Redis (not a per-process counter that would multiply with replicas), and click counts go through the queue (no contention on the hot path). Scaling is the reward for that architecture — any replica can serve any request.