Docker & CI — Shipping Rust to Production
Why multi-stage Docker matters for Rust
Section titled “Why multi-stage Docker matters for Rust”A Node.js Docker image must include the Node runtime at ~180 MB (Alpine) just to run. A Rust binary has no runtime dependency, so you can copy it into a distroless or scratch image. The result is a production image that is often 5–30 MB.
The trick is a multi-stage build: a full Rust toolchain in the builder stage, and only the compiled binary in the final stage.
Dockerfile comparison
Section titled “Dockerfile comparison”# Node / TypeScript Dockerfile (multi-stage)FROM node:20-alpine AS builderWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run build
FROM node:20-alpine AS runtimeWORKDIR /appCOPY --from=builder /app/dist ./distCOPY --from=builder /app/node_modules ./node_modulesEXPOSE 3000CMD ["node", "dist/index.js"]# Final image: ~180 MB (Node runtime required)# Rust Dockerfile (multi-stage → distroless)FROM rust:1.80-slim AS builderWORKDIR /app# Cache dependencies separately from source codeCOPY Cargo.toml Cargo.lock ./RUN mkdir src && echo "fn main() {}" > src/main.rsRUN cargo build --release --lockedRUN rm -rf src
# Now build the real sourceCOPY src ./srcRUN touch src/main.rs && cargo build --release --locked
FROM gcr.io/distroless/cc-debian12 AS runtimeCOPY --from=builder /app/target/release/my-app /usr/local/bin/my-appEXPOSE 8080CMD ["/usr/local/bin/my-app"]# Final image: ~20 MB (no runtime needed)The dependency-caching trick explained
Section titled “The dependency-caching trick explained”The two-step COPY pattern above is critical for fast rebuilds:
# Step 1: copy only manifests and build a stub main.rs# Docker layer-caches this — only invalidated when Cargo.toml/Cargo.lock changesCOPY Cargo.toml Cargo.lock ./RUN mkdir src && echo "fn main() {}" > src/main.rsRUN cargo build --release --locked
# Step 2: copy real source — this layer is invalidated on every code change# but dependency compilation is already cachedCOPY src ./srcRUN touch src/main.rs && cargo build --release --lockedThis is equivalent to the Node pattern of COPY package*.json ./ then RUN npm ci before COPY . ..
GitHub Actions workflow
Section titled “GitHub Actions workflow”name: CI
on: push: branches: [main, dev] pull_request: branches: [main]
env: CARGO_TERM_COLOR: always
jobs: check: name: Check, Lint & Test runs-on: ubuntu-latest
steps: - uses: actions/checkout@v4
- name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy
- name: Cache Cargo registry and build artifacts uses: Swatinem/rust-cache@v2
- name: Check formatting run: cargo fmt --check
- name: Clippy (lint) run: cargo clippy -- -D warnings
- name: Run tests run: cargo test --locked
- name: Build release binary run: cargo build --release --lockedNode CI comparison
Section titled “Node CI comparison”# .github/workflows/ci.yml (Node / TypeScript)name: CIon: [push, pull_request]jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - run: npm ci - run: npx eslint src/ - run: npx prettier --check "src/**/*.ts" - run: npm test - run: npm run buildThe Rust workflow uses dtolnay/rust-toolchain (the official action) and Swatinem/rust-cache to cache the ~/.cargo registry and target/ between runs — the equivalent of actions/setup-node with cache: 'npm'.
These are Dockerfile and YAML configurations. There is no browser Playground for Docker builds — use these files in your project and push to GitHub to trigger the Actions workflow.