M Logo
๐Ÿ“ Rust Backend with Axum ยท Part 36 of 42

Rust Web Server with Axum 0.8: Docker, One Image That Runs Anywhere

November 3, 2026

Episode 36 of the Rust Backend series, where we build the backend of a blog step by step. After episode 35 our API has automatic tests. Now: "it works on my machine". Our server needs the right Rust, a database file, a Redis, and settings. Today we put the server into one Docker image that runs the same everywhere. All the code below is the final, tested code from the video, ready to copy.

We continue in the same blog_api project from episode 35. You need Docker (on Ubuntu: sudo apt install docker.io). No Rust changes today: just two new files.

1. Images and containers

An image is a frozen file system plus a start command. A container is an image that runs. A Dockerfile describes how to build the image.

2. Two stages

Building Rust needs the whole toolchain: over a gigabyte. Running needs only our binary. So two stages: build in the big rust image, then copy the binary into a small debian-slim one.

dockerfile
FROM rust:1.99-slim AS build
WORKDIR /app
RUN apt-get update && apt-get install -y curl
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release && rm -rf src
COPY . .
RUN touch src/main.rs && cargo build --release

FROM debian:trixie-slim
COPY --from=build /app/target/release/blog_api /usr/local/bin/blog_api
CMD ["blog_api"]

The trick is the dependency layer: we copy only Cargo.toml and Cargo.lock first, and build them with an empty main. Docker caches every step, so as long as our dependencies don't change, that slow step is reused. Then we copy our real code and build again; touch makes sure cargo sees main.rs as new.

utoipa-swagger-ui (episode 34) downloads the Swagger page while it builds, so the build stage installs curl. Both images use the same Debian release, so the binary finds the same system libraries.

3. .dockerignore

.dockerignore keeps files out of the build. The target folder, the database, .env and uploads stay outside the image:

text
target
.env
blog.db*
uploads
.git

Secrets never go into an image: anyone who can pull it can read every file inside.

4. Build, and SQLX_OFFLINE

bash
docker build -t blog-api . 2>&1 | tail -22

It works, and there is no database inside the build. The query! macros read the .sqlx folder from episode 10 (cargo sqlx prepare), because it is part of the build folder. We make that explicit, so a build never depends on a database by accident:

dockerfile
FROM rust:1.99-slim AS build
WORKDIR /app
ENV SQLX_OFFLINE=true

SQLX_OFFLINE=true tells the macros: use only .sqlx, never connect. Build again:

bash
docker build -t blog-api . 2>&1 | tail -8

The dependencies are cached now; only our crate compiles.

5. Settings come from outside

bash
docker run --rm -p 8080:8080 blog-api

It panics: JWT_SECRET must be set. The image has no .env, and that is good: settings come from outside. With the secret as an environment variable (the demo value from episode 18):

bash
docker run --rm -p 8080:8080 -e JWT_SECRET=5c0e8f1b3a9d47e2b6f0a4c8d1e7239f6b5a0c3d8e1f47a29b6c5d0e3f8a1b47 blog-api

Another panic: connection refused. Inside a container, localhost is the container itself, and Redis is not there.

6. A network for Redis

Redis runs in its own container, and both join one Docker network. Containers on a network find each other by name:

bash
docker network create blog && docker run -d --name redis --network blog redis:7

So our server's Redis address becomes redis://redis:6379.

7. Host, data volume, non-root user

The second stage gets the settings that are the same everywhere, a data folder that is a volume, and a user that is not root:

dockerfile
FROM debian:trixie-slim
RUN useradd -r app && mkdir /data && chown app /data
WORKDIR /data
COPY --from=build /app/target/release/blog_api /usr/local/bin/blog_api
USER app
ENV HOST=0.0.0.0 PORT=8080 DATABASE_URL=sqlite:///data/blog.db RUST_LOG=info
VOLUME /data
EXPOSE 8080
CMD ["blog_api"]
  • HOST=0.0.0.0: listen on every interface. 127.0.0.1 inside a container would only accept connections from the container itself.
  • The database lives in /data, a volume, so it survives the container.
  • USER app: if someone breaks into our server, they are not root.
  • WORKDIR /data: our relative uploads folder lands in the volume too.

8. Run it for real

bash
docker build -t blog-api . 2>&1 | tail -4
docker run -d --name blog --network blog -p 8080:8080 -v blog-data:/data -e JWT_SECRET=5c0e8f1b3a9d47e2b6f0a4c8d1e7239f6b5a0c3d8e1f47a29b6c5d0e3f8a1b47 -e REDIS_URL=redis://redis:6379 blog-api
docker logs blog 2>&1 | tail -5

The log shows the migrations and listening. Sign up, log in, make a post:

bash
curl -s localhost:8080/signup -H 'content-type: application/json' -d '{"email":"cara@example.com","password":"hunter2hunter2"}' | jq -c .id
curl -si localhost:8080/posts -H 'content-type: application/json' -H "authorization: Bearer $TOKEN" -d '{"title":"From Docker","body":"Hi."}' | head -1
text
HTTP/1.1 201 Created

Remove the container, and start a new one with the same volume:

bash
docker rm -f blog && docker run -d --name blog --network blog -p 8080:8080 -v blog-data:/data -e JWT_SECRET=5c0e8f1b3a9d47e2b6f0a4c8d1e7239f6b5a0c3d8e1f47a29b6c5d0e3f8a1b47 -e REDIS_URL=redis://redis:6379 blog-api
curl -s localhost:8080/posts | jq -c '.items[].title'
text
"From Docker"

The post is still there.

9. Size

bash
docker images --format '{{.Repository}}:{{.Tag}}  {{.Size}}' | grep -E 'blog-api|rust'

Our image is about a hundred megabytes. The rust image alone is over a gigabyte.

The final code

.dockerignore

text
target
.env
blog.db*
uploads
.git

Dockerfile

text
FROM rust:1.99-slim AS build
WORKDIR /app
ENV SQLX_OFFLINE=true
RUN apt-get update && apt-get install -y curl
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release && rm -rf src
COPY . .
RUN touch src/main.rs && cargo build --release

FROM debian:trixie-slim
RUN useradd -r app && mkdir /data && chown app /data
WORKDIR /data
COPY --from=build /app/target/release/blog_api /usr/local/bin/blog_api
USER app
ENV HOST=0.0.0.0 PORT=8080 DATABASE_URL=sqlite:///data/blog.db RUST_LOG=info
VOLUME /data
EXPOSE 8080
CMD ["blog_api"]

Recap

  • Two stages: build with the full toolchain, ship only the binary on debian-slim
  • Copy Cargo.toml/Cargo.lock first and build an empty main: a cached dependency layer
  • SQLX_OFFLINE=true builds from .sqlx, without a database
  • .dockerignore keeps .env, the database and target out; secrets come in as environment variables
  • Inside a container localhost is the container: use a network and container names, HOST=0.0.0.0
  • A volume for data, and a non-root user

Next episode: GitHub Actions. Tests, lints and the Docker image on every push. Watch the series on YouTube.

ยฉ 2026 All Rights Reserved By Mahmudul Alam