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.
- New to Rust? Start with the beginner series.
- The full backend series playlist is here.
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.
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:
target
.env
blog.db*
uploads
.gitSecrets never go into an image: anyone who can pull it can read every file inside.
4. Build, and SQLX_OFFLINE
docker build -t blog-api . 2>&1 | tail -22It 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:
FROM rust:1.99-slim AS build
WORKDIR /app
ENV SQLX_OFFLINE=trueSQLX_OFFLINE=true tells the macros: use only .sqlx, never connect. Build again:
docker build -t blog-api . 2>&1 | tail -8The dependencies are cached now; only our crate compiles.
5. Settings come from outside
docker run --rm -p 8080:8080 blog-apiIt 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):
docker run --rm -p 8080:8080 -e JWT_SECRET=5c0e8f1b3a9d47e2b6f0a4c8d1e7239f6b5a0c3d8e1f47a29b6c5d0e3f8a1b47 blog-apiAnother 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:
docker network create blog && docker run -d --name redis --network blog redis:7So 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:
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.1inside 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 relativeuploadsfolder lands in the volume too.
8. Run it for real
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 -5The log shows the migrations and listening. Sign up, log in, make a post:
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 -1HTTP/1.1 201 CreatedRemove the container, and start a new one with the same volume:
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'"From Docker"The post is still there.
9. Size
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
target
.env
blog.db*
uploads
.gitDockerfile
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.lockfirst and build an emptymain: a cached dependency layer SQLX_OFFLINE=truebuilds from.sqlx, without a database.dockerignorekeeps.env, the database andtargetout; secrets come in as environment variables- Inside a container
localhostis 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.