Skip to content

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 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 builder
WORKDIR /app
COPY Cargo.toml Cargo.lock* ./
COPY src ./src
COPY migrations ./migrations
RUN cargo build --release --bin snip --bin worker
# ---- runtime stage ----
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/snip /usr/local/bin/snip
COPY --from=builder /app/target/release/worker /usr/local/bin/worker
EXPOSE 8080
CMD ["snip"]

Walking it:

  • FROM rust:1-bookworm AS builder — the build stage, named builder. It has the full Rust toolchain. The AS name is how the second stage refers back to it.
  • COPY then cargo build --release — copy in the manifests, source, and migrations, then compile both binaries (--bin snip --bin worker) in --release mode (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 only ca-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. :::

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:
- app

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.

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).

  • 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, so postgres:5432 just resolves.
  • depends_on with condition: service_healthy — this is why the healthcheck mattered. The app won’t start until Postgres reports healthy (not merely started). Redis only needs service_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.

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: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.

Terminal window
docker compose up --build # postgres + redis + app + worker + nginx
docker 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. :::

Terminal window
# 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 replicas

Compare 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.

  1. What problem does a multi-stage Dockerfile solve, and which line copies the result of the build stage into the runtime stage?
  2. The worker service uses build: . just like app. How does it end up running a different program?
  3. Why does the app service wait for condition: service_healthy on Postgres but only condition: service_started on Redis?
  4. Only one service publishes a host port. Which one, and why is it correct that the app containers don’t publish ports of their own?
  5. What earlier architectural choices make docker compose up --scale app=3 safe to run?
Show answers
  1. It solves shipping the whole build toolchain in the runtime image: the rust image is well over a gigabyte, but none of the compiler, cargo, or source is needed to run the binary. You compile in a fat builder stage and then COPY --from=builder /app/target/release/snip /usr/local/bin/snip (and the same for worker) into a slim debian:bookworm-slim stage — that COPY --from=builder ... line is what carries the build result across, leaving a tens-of-megabytes image instead of a gigabyte-plus one.
  2. Both binaries (snip and worker) are baked into the one image by cargo build --bin snip --bin worker. The Dockerfile’s CMD ["snip"] runs the API by default, but the worker service overrides it with command: ["worker"], so the same image runs a different program. One image, two roles.
  3. 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_isready healthcheck reporting healthy, not merely started. Redis only needs service_started because the app tolerates a cold cache (it degrades to reading Postgres), so there’s nothing to wait for.
  4. nginx publishes "8080:80" — it’s the front door. The app containers 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.
  5. 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.