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:
git checkout api-ch14inside 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.
The configuration is already there
Section titled “The configuration is already there”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 ofsrc/main.rs, before the pool connectsSHORTLINK_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:
docker run --rm --network shortlink-net \ -e DATABASE_URL=postgres://postgres:dev@shortlink-pg:5432/shortlink \ shortlink:latest2026-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: NotPresentnote: run with `RUST_BACKTRACE=1` environment variable to display a backtraceThe 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.
The Dockerfile
Section titled “The Dockerfile”# ---- build stage: the toolchain, the whole source tree ----FROM rust:1.99.0-slim AS buildWORKDIR /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 migrationsRUN touch src/main.rs && cargo build --release
# ---- runtime stage: one binary, nothing else ----FROM debian:trixie-slimCOPY --from=build /build/target/release/shortlink /usr/local/bin/shortlinkUSER nobodyEXPOSE 8000ENTRYPOINT ["/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.
What .dockerignore is for
Section titled “What .dockerignore is for”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.
Building it
Section titled “Building it”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 13sStep 8/13 : RUN touch src/main.rs && cargo build --release Compiling shortlink v0.1.0 (/build) Finished `release` profile [optimized] target(s) in 7.25sStep 13/13 : ENTRYPOINT ["/usr/local/bin/shortlink"]Successfully built ede0f263901fSuccessfully 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 cacheStep 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.16sSteps 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.
The image that comes out
Section titled “The image that comes out”docker images --format '{{.Repository}}:{{.Tag}} {{.Size}}'shortlink:latest 151MBrust:1.99.0-slim 1.14GBdebian:trixie-slim 142MBThe 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:
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/shortlinkOne 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.
Running it against the real database
Section titled “Running it against the real database”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:
docker network create shortlink-netdocker network connect shortlink-net shortlink-pgdocker 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, skipping2026-10-18T21:44:31.356197Z INFO shortlink: listening on 0.0.0.0:8000Then the curls every step since 8 has used, now against the container:
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/healthcurl -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/linkscurl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/zzzzzzz200{"code":"34euqTO","url":"https://example.org/container"}404The code is generated from a clock-seeded sequence (step 9), so yours will
not be 34euqTO. Resolving it redirects:
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://127.0.0.1:8000/34euqTO302 https://example.org/containerAnd 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=2012026-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=4042026-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=302Nothing 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.
fly.toml, and what a deploy actually is
Section titled “fly.toml, and what a deploy actually is”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:05ZWithout 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:
fly auth loginfly launch --no-deployfly postgres create --name shortlink-dbfly postgres attach shortlink-dbfly secrets set SHORTLINK_API_KEY=choose-your-ownfly deployfly 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.
Where the track ends
Section titled “Where the track ends”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.