Skip to main content
⚡ Calmops

Production Deployment in Rust: Docker, CI/CD, and Monitoring

Deploy Rust applications with Docker, automate with GitHub Actions, monitor with Prometheus

Moving a Rust application from development to production requires careful attention to deployment strategy, automation, and observability. This article covers containerization with Docker, continuous integration/deployment with GitHub Actions, and monitoring with Prometheus.


Deployment Architecture

Here’s a typical production Rust deployment architecture:

┌─────────────────────────────────────────────────────────────┐
│                     Client Applications                      │
└──────────────────┬──────────────────────────────────────────┘
                   │
┌──────────────────┴──────────────────┐
│        Load Balancer / Ingress       │
│      (NGINX, HAProxy, K8s Ingress)   │
└──────────────────┬──────────────────┘
                   │
        ┌──────────┼──────────┐
        │          │          │
    ┌───▼──┐   ┌──▼───┐   ┌──▼───┐
    │ Pod1 │   │ Pod2 │   │ Pod3 │  (Rust Services in Docker)
    │(Rust)│   │(Rust)│   │(Rust)│
    └───┬──┘   └──┬───┘   └──┬───┘
        │         │         │
        └─────────┼─────────┘
                  │
        ┌─────────┴──────────┐
        │   Database         │
        │  (PostgreSQL)      │
        └────────────────────┘
        
        ┌─────────────────────┐
        │  Monitoring Stack   │
        │ Prometheus + Grafana│
        └─────────────────────┘

Docker: Containerization

Minimalist Dockerfile (Multi-stage Build)

The key to efficient Rust Docker images is multi-stage builds:

# Build stage
FROM rust:1.75 as builder

WORKDIR /app

# Copy manifests
COPY Cargo.toml Cargo.lock ./

# Copy source
COPY src ./src

# Build release binary
RUN cargo build --release

# Runtime stage
FROM debian:bookworm-slim

# Install runtime dependencies
RUN apt-get update && apt-get install -y \
    ca-certificates \
    libssl3 \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Copy binary from builder
COPY --from=builder /app/target/release/my_app /app/my_app

# Non-root user for security
RUN useradd -m appuser
USER appuser

EXPOSE 8080

CMD ["./my_app"]

Why multi-stage? The builder stage is large (Rust compiler, dependencies). The final image only includes the compiled binary, keeping it small.

Optimized Dockerfile with Caching

FROM rust:1.75 as builder

WORKDIR /app

# Copy and build dependencies first (better caching)
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm -rf src

# Now copy actual source and rebuild
COPY src ./src
RUN cargo build --release

# Runtime
FROM debian:bookworm-slim

RUN apt-get update && apt-get install -y \
    ca-certificates libssl3 \
    && rm -rf /var/lib/apt/lists/*

COPY --from=builder /app/target/release/my_app /app/my_app

RUN useradd -m appuser && chown -R appuser:appuser /app
USER appuser

EXPOSE 8080
CMD ["./my_app"]

Docker Compose for Local Development

# docker-compose.yml
version: '3.8'

services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      DATABASE_URL: postgresql://user:password@db:5432/myapp
      RUST_LOG: debug
    depends_on:
      - db
    volumes:
      - .:/app

  db:
    image: postgres:15
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
      POSTGRES_DB: myapp
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

  prometheus:
    image: prom/prometheus:latest
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
      - prometheus_data:/prometheus

volumes:
  postgres_data:
  prometheus_data:

CI/CD with GitHub Actions

Basic GitHub Actions Workflow

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  CARGO_TERM_COLOR: always
  RUST_BACKTRACE: 1

jobs:
  test:
    name: Test
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - uses: dtolnay/rust-toolchain@stable
      
      - uses: Swatinem/rust-cache@v2
      
      - name: Run tests
        run: cargo test --verbose
      
      - name: Run doctests
        run: cargo test --doc

  clippy:
    name: Clippy
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      
      - name: Run clippy
        run: cargo clippy -- -D warnings

  format:
    name: Format
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      
      - name: Check formatting
        run: cargo fmt -- --check

Docker Build and Push

# .github/workflows/docker.yml
name: Docker Build and Push

on:
  push:
    branches: [main]
    tags: ['v*']

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    
    permissions:
      contents: read
      packages: write
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      
      - name: Login to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      
      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=ref,event=branch
            type=semver,pattern={{version}}
            type=sha
      
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Monitoring with Prometheus and Grafana

Instrumenting Rust Code

// filepath: src/metrics.rs
use prometheus::{Counter, Histogram, Registry, TextEncoder, Encoder};
use lazy_static::lazy_static;

lazy_static! {
    pub static ref HTTP_REQUESTS_TOTAL: Counter =
        Counter::new("http_requests_total", "Total HTTP requests").unwrap();
    
    pub static ref HTTP_REQUEST_DURATION_SECONDS: Histogram =
        Histogram::new("http_request_duration_seconds", "HTTP request duration").unwrap();
    
    pub static ref DATABASE_QUERIES_TOTAL: Counter =
        Counter::new("database_queries_total", "Total database queries").unwrap();
    
    pub static ref DATABASE_QUERY_ERRORS_TOTAL: Counter =
        Counter::new("database_query_errors_total", "Total database query errors").unwrap();
}

pub fn get_metrics() -> String {
    let encoder = TextEncoder::new();
    let metric_families = prometheus::gather();
    encoder.encode(&metric_families, &mut Vec::new()).unwrap();
    
    let mut buffer = Vec::new();
    encoder.encode(&metric_families, &mut buffer).unwrap();
    String::from_utf8(buffer).unwrap()
}

Using Metrics in Axum

// filepath: src/main.rs
use axum::{
    routing::get,
    response::IntoResponse,
    Router, extract::State,
};
use std::sync::Arc;
use std::time::Instant;

#[derive(Clone)]
pub struct AppState {
    // Your state fields
}

pub async fn health() -> &'static str {
    metrics::HTTP_REQUESTS_TOTAL.inc();
    "OK"
}

pub async fn api_endpoint() -> impl IntoResponse {
    let start = Instant::now();
    metrics::HTTP_REQUESTS_TOTAL.inc();
    
    // Your endpoint logic
    let result = "Success".to_string();
    
    let duration = start.elapsed().as_secs_f64();
    metrics::HTTP_REQUEST_DURATION_SECONDS.observe(duration);
    
    result
}

pub async fn metrics_handler() -> impl IntoResponse {
    metrics::get_metrics()
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/health", get(health))
        .route("/api/endpoint", get(api_endpoint))
        .route("/metrics", get(metrics_handler));
    
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080")
        .await
        .unwrap();
    
    axum::serve(listener, app).await.unwrap();
}

Prometheus Configuration

# prometheus.yml
global:
  scrape_interval: 15s
  evaluation_interval: 15s

scrape_configs:
  - job_name: 'rust-app'
    static_configs:
      - targets: ['localhost:8080']
    metrics_path: '/metrics'
    scrape_interval: 5s
    scrape_timeout: 1s

Grafana Dashboard Query

# Requests per second
rate(http_requests_total[5m])

# 95th percentile latency
histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))

# Error rate
rate(database_query_errors_total[5m]) / rate(database_queries_total[5m])

Common Pitfalls and Best Practices

❌ Pitfall 1: Large Docker Images

# ❌ Bad: Includes compiler in runtime image
FROM rust:1.75
COPY . .
RUN cargo build --release
CMD ["./target/release/app"]

# ✅ Good: Multi-stage build
FROM rust:1.75 as builder
COPY . .
RUN cargo build --release

FROM debian:bookworm-slim
COPY --from=builder /app/target/release/app /app/app
CMD ["./app"]

❌ Pitfall 2: Running as Root in Container

# ❌ Bad: Runs as root
FROM debian:bookworm-slim
COPY app /app
CMD ["./app"]

# ✅ Good: Non-root user
FROM debian:bookworm-slim
RUN useradd -m appuser
COPY --chown=appuser:appuser app /app
USER appuser
CMD ["./app"]

✅ Best Practice 1: Health Checks

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD curl -f http://localhost:8080/health || exit 1
pub async fn health() -> (axum::http::StatusCode, &'static str) {
    // Check database connectivity
    if check_db_health().await {
        (axum::http::StatusCode::OK, "Healthy")
    } else {
        (axum::http::StatusCode::SERVICE_UNAVAILABLE, "Unhealthy")
    }
}

✅ Best Practice 2: Graceful Shutdown

use tokio::signal;

#[tokio::main]
async fn main() {
    let app = create_router();
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
    
    let server = axum::serve(listener, app);
    
    let shutdown = async {
        signal::ctrl_c().await.expect("Failed to install CTRL+C handler");
    };
    
    match server.with_graceful_shutdown(shutdown).await {
        Ok(_) => println!("Server shut down gracefully"),
        Err(e) => eprintln!("Server error: {}", e),
    }
}

✅ Best Practice 3: Environment-Based Configuration

use serde::Deserialize;

#[derive(Deserialize)]
pub struct Config {
    pub database_url: String,
    pub server_port: u16,
    pub log_level: String,
    pub metrics_enabled: bool,
}

impl Config {
    pub fn from_env() -> Self {
        Config {
            database_url: std::env::var("DATABASE_URL")
                .expect("DATABASE_URL not set"),
            server_port: std::env::var("SERVER_PORT")
                .unwrap_or("8080".to_string())
                .parse()
                .unwrap(),
            log_level: std::env::var("RUST_LOG")
                .unwrap_or("info".to_string()),
            metrics_enabled: std::env::var("METRICS_ENABLED")
                .unwrap_or("true".to_string())
                .parse()
                .unwrap_or(true),
        }
    }
}

Comparison with Other Languages

Aspect Rust Go Python Node.js
Docker Image Size 50-100MB 30-60MB 100-200MB 80-150MB
Startup Time <100ms <50ms 100-500ms 50-200ms
Memory Usage 10-50MB 5-25MB 50-150MB 30-100MB
Compile Time Slow Fast N/A N/A
Monitoring Prometheus Prometheus StatsD Prometheus

Further Resources

Docker and Containerization

CI/CD with GitHub Actions

Monitoring and Observability

Helpful Crates

Alternative Technologies


Deployment Checklist

  • Multi-stage Docker build configured
  • Non-root user in Dockerfile
  • Health check endpoint implemented
  • Graceful shutdown handling
  • Environment-based configuration
  • Prometheus metrics exposed
  • GitHub Actions CI/CD configured
  • Code coverage tracking
  • Security scanning enabled
  • Docker image registry configured
  • Prometheus scrape configuration
  • Grafana dashboards created
  • Log aggregation configured
  • Alert rules defined

Conclusion

Production-ready Rust deployments combine:

  1. Efficient Docker images - Multi-stage builds minimize size
  2. Automated testing - CI/CD catches issues early
  3. Graceful deployment - Health checks and shutdown handling
  4. Comprehensive monitoring - Prometheus + Grafana visibility
  5. Structured logging - Debugging and troubleshooting

These practices ensure your Rust applications are reliable, observable, and maintainable in production.



Deploy with confidence! 🚀

Comments