Skip to content
Select theme

Docker, config, and deploying to production

Step 15 of 15, and the last one. Fourteen steps in, the service exists only on the machine that compiled it: the binary reaches a Postgres on localhost:5432, and both of its configuration values arrive as exported shell variables. This step wraps the binary in an image that carries nothing but the binary, shows what the configuration looks like from the outside (which is: unchanged), and walks up to the edge of an actual deploy.

Three files get added - Dockerfile, .dockerignore, fly.toml - and nothing else changes. No Rust is edited, no dependency is added; Cargo.toml is the same file step 14 left it. Start from the previous step’s end state:

Terminal window
git checkout api-ch14

inside axum-sqlx-api/ in your clone of the examples repository, with the Postgres container from step 5 still running. You also need Docker itself; this page ran it as version 29.5.2 in a Linux VM on a Mac, and the outputs below carry that machine’s timestamps, container ids and generated codes - yours will differ in exactly those places.

Nothing in this step is about config files, because the service has had none since step 5. It reads two variables from the environment at startup, and refuses to start without them:

  • DATABASE_URL, read on line 763 of src/main.rs, before the pool connects
  • SHORTLINK_API_KEY, read on line 776, after the migrations

A container is just another machine. The same two variables, the same behavior when one is missing. Run the image with a database but no API key and the container dies on boot:

Terminal window
docker run --rm --network shortlink-net \
-e DATABASE_URL=postgres://postgres:dev@shortlink-pg:5432/shortlink \
shortlink:latest
2026-10-18T21:41:05.823732Z INFO sqlx::postgres::notice: relation "_sqlx_migrations" already exists, skipping
thread 'main' (1) panicked at src/main.rs:776:50:
SHORTLINK_API_KEY is not set: NotPresent
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

The first line is sqlx noting that the migration table already exists on a database that has been migrated before - migrations ran fine. The panic is the part that matters: the process exits with code 101, the orchestrator running it sees a dead container, and the log names the exact missing variable. The (1) after main is the thread id, which stays small inside a container because the process is the first thing in it. That refusal to half-start is step 11’s design decision paying off in production clothing.

# ---- build stage: the toolchain, the whole source tree ----
FROM rust:1.99.0-slim AS build
WORKDIR /build
# Dependencies first, source last. Cargo.lock and the offline query cache
# (.sqlx/) go in before the code, so a source-only change reuses the layer
# that downloaded and compiled every dependency.
COPY Cargo.toml Cargo.lock ./
COPY .sqlx .sqlx
# A stub main compiles the dependency graph alone. The real main.rs, with
# the query! macros, replaces it after - the stub exists so the expensive
# layer can be cached without the source that changes on every edit.
RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release
COPY src src
# sqlx::migrate! reads this directory at compile time - it is part of the
# binary, not a file the container ships.
COPY migrations migrations
RUN touch src/main.rs && cargo build --release
# ---- runtime stage: one binary, nothing else ----
FROM debian:trixie-slim
COPY --from=build /build/target/release/shortlink /usr/local/bin/shortlink
USER nobody
EXPOSE 8000
ENTRYPOINT ["/usr/local/bin/shortlink"]

Four decisions in there, each earning its lines:

The builder is pinned to rust:1.99.0-slim - the same compiler the whole track has been compiled with since step 2 (rust-toolchain.toml pins it locally; the image pins it in the cloud). The image is Debian 13 (“trixie”), and the runtime stage below is the same Debian release, which matters because the release build links the system’s C library dynamically - a binary built against trixie’s glibc 2.41 expects a trixie runtime.

Dependencies are built before the source exists. The stub fn main() {} gives cargo something to link, so the 178 dependency crates compile once into a layer that survives any edit to src/. On this machine the stub build takes 1m 13s; the real crate takes 7 seconds.

.sqlx/ travels with the manifests - step 7’s offline query cache. The query! macros check every statement against the JSON files instead of a live database, so the image builds with no Postgres in sight. Delete that line and the build needs a running database at docker build time, which is a bad trade on a CI runner.

migrations/ is copied before the final build because sqlx::migrate!() embeds the directory into the binary at compile time (step 6). The runtime image ships no SQL files; there is nothing to get out of sync.

target/

One line. Without it, docker build ships your entire target/ directory to the daemon as build context - several gigabytes of build artifacts the builder is about to recreate from scratch. With it, the context for this project is 98.3 kB.

Terminal window
docker build -t shortlink .
Step 5/13 : RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release
Compiling proc-macro2 v1.0.107
...
Finished `release` profile [optimized] target(s) in 1m 13s
Step 8/13 : RUN touch src/main.rs && cargo build --release
Compiling shortlink v0.1.0 (/build)
Finished `release` profile [optimized] target(s) in 7.25s
Step 13/13 : ENTRYPOINT ["/usr/local/bin/shortlink"]
Successfully built ede0f263901f
Successfully tagged shortlink:latest

(The output here comes from an older builder that prints Step N/13; most Docker installs now use BuildKit, which shows the same steps as => [build 5/6] RUN ... and CACHED instead of Using cache. The work is the same either way.)

The cache payoff is worth seeing once. Edit a line in src/main.rs - anything - and rebuild:

Step 5/13 : RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release
---> Using cache
Step 6/13 : COPY src src
...
Step 8/13 : RUN touch src/main.rs && cargo build --release
Compiling shortlink v0.1.0 (/build)
Finished `release` profile [optimized] target(s) in 7.16s

Steps 2 through 5 are reused, dependencies included; only the crate recompiles. That 1m13s is paid when Cargo.toml or Cargo.lock changes, not when you write code.

Terminal window
docker images --format '{{.Repository}}:{{.Tag}} {{.Size}}'
shortlink:latest 151MB
rust:1.99.0-slim 1.14GB
debian:trixie-slim 142MB

The 1.14 GB toolchain stayed in the build stage. What ships is the 151 MB runtime image - 142 MB of Debian base plus the binary and almost nothing else:

Terminal window
docker run --rm --entrypoint ls shortlink:latest -lh /usr/local/bin/shortlink
-rwxr-xr-x 1 root root 8.1M Oct 18 21:34 /usr/local/bin/shortlink

One file, 8.1 MB, owned by root but executed as nobody. Two honest footnotes. First, the build stage does not fully disappear: it lingers on your disk as a dangling image (2.23 GB here, toolchain plus target/), which docker image prune reclaims. Second, debian:trixie-slim ships no CA certificates - true of the running service today, which makes no outbound HTTPS calls, but the first feature that fetches a URL over TLS will also need apt-get install ca-certificates in the runtime stage.

The container joins the same Docker network as the Postgres container from step 5, so DATABASE_URL can name the database by its container name:

Terminal window
docker network create shortlink-net
docker network connect shortlink-net shortlink-pg
docker run -d --name shortlink-app \
--network shortlink-net \
-p 8000:8000 \
-e DATABASE_URL=postgres://postgres:dev@shortlink-pg:5432/shortlink \
-e SHORTLINK_API_KEY=prod-key \
shortlink:latest

(shortlink-pg is step 5’s container name and prod-key is this demo’s key - yours may be named differently; the rest is not negotiable, because the run command is the entire deployment configuration.)

Boot is two log lines:

2026-10-18T21:44:31.355131Z INFO sqlx::postgres::notice: relation "_sqlx_migrations" already exists, skipping
2026-10-18T21:44:31.356197Z INFO shortlink: listening on 0.0.0.0:8000

Then the curls every step since 8 has used, now against the container:

Terminal window
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/health
curl -s -X POST \
-H 'authorization: Bearer prod-key' \
-H 'content-type: application/json' \
-d '{"url":"https://example.org/container"}' \
http://127.0.0.1:8000/links
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/zzzzzzz
200
{"code":"34euqTO","url":"https://example.org/container"}
404

The code is generated from a clock-seeded sequence (step 9), so yours will not be 34euqTO. Resolving it redirects:

Terminal window
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://127.0.0.1:8000/34euqTO
302 https://example.org/container

And docker logs shortlink-app shows step 13’s spans, request ids included, arriving exactly as they did in a terminal - the platform sees the same stdout you do:

2026-10-18T21:44:33.440601Z INFO http_request{request_id=4bd1a99b-4595-43d9-b870-d04a0cefcd02 method=POST path=/links}: tower_http::trace::on_response: finished processing request latency=1 ms status=201
2026-10-18T21:44:33.461380Z INFO http_request{request_id=cd17320a-f07f-494e-8aaa-809b0e055329 method=GET path=/zzzzzzz}: tower_http::trace::on_response: finished processing request latency=0 ms status=404
2026-10-18T21:44:44.447747Z INFO http_request{request_id=2d16e2c3-058e-4e50-a849-61a2135d2d94 method=GET path=/34euqTO}: tower_http::trace::on_response: finished processing request latency=0 ms status=302

Nothing in the service knew it was in a container. That is the point of the last three steps’ design choices: logs to stdout, no config files, no state on disk.

A deploy is: the image gets built on a machine that is not yours, gets an address, and gets its two environment variables. Fly.io is one host that works exactly this way, and this page stops where its account starts - everything below is real except the steps that need a paid login, and those are shown as the commands they are.

The CLI is flyctl (brew install flyctl on a Mac; this machine’s copy is the GitHub release v0.4.118):

flyctl v0.4.118 darwin/arm64 Commit: 184af3a759d36db248a0477791125a6ba8081014 BuildDate: 2026-10-14T16:11:05Z

Without a login it says so:

Error: no access token available. Please login with 'flyctl auth login'

The configuration file describes the app to the platform; fly launch generates one, and the generated shape looks like this:

app = "shortlink"
primary_region = "ams"
[build]
[http_service]
internal_port = 8000
force_https = true
auto_stop_machines = "stop"
auto_start_machines = true
min_machines_running = 0
[[vm]]
size = "shared-cpu-1x"
memory = "256mb"

The one line that connects this file to the service is internal_port = 8000: the platform routes public traffic to that port inside the container, and the container listens there because step 2 bound 0.0.0.0:8000 - not localhost, which is the classic deploy bug (the proxy reaches the machine, the app never answers). Fly’s docs recommend letting apps use their default of 8080; that means reading a PORT environment variable in main, one line, and it is a fair exercise. The empty [build] section tells the platform to use the Dockerfile it finds next to this file - the multi-stage one from above, rebuilt on their machines.

The rest of a first deploy, as commands - run from the project directory, where fly.toml tells flyctl which app you mean:

Terminal window
fly auth login
fly launch --no-deploy
fly postgres create --name shortlink-db
fly postgres attach shortlink-db
fly secrets set SHORTLINK_API_KEY=choose-your-own
fly deploy

fly postgres attach prints a connection string and sets it on the app as a secret named DATABASE_URL - the same variable the code has read since step 5. After that, fly deploy builds the image from your Dockerfile and ships it. What this page cannot show is the output of those commands, because every one of them needs a Fly account, and pasted deploy output that was never run is exactly the kind of thing this track refuses to publish.

Fifteen steps ago the contract was four sentences on an overview page. What exists now: an axum router with path and query extractors (3), one JSON envelope and IntoResponse (4), a Postgres pool with honest connection failures (5), SQL migrations (6) and compile-time-checked queries (7), the create-and-resolve paths (8) with base62 codes, a unique index and collision retries (9), validation and one error type (10), API-key auth behind a tower layer (11), a per-key token bucket (12), request-traced logs (13), integration tests against a real database (14), and now an image that runs the whole thing on any machine with Docker. git checkout api-ch15 gets you the final state; cargo test still passes twelve, and docker build -t shortlink . still builds.

The track overview - A production API with Axum and SQLx - keeps the full list, and the exercises branch of the examples repository holds the leftovers: a PORT variable, HTTPS for outbound calls, and swapping the in-memory rate limiter for one that survives a second instance.