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
- Docker Documentation: https://docs.docker.com/
- Best Practices for Writing Dockerfiles: https://docs.docker.com/develop/develop-images/dockerfile_best-practices/
- Dive: Tool to explore Docker image layers https://github.com/wagoodman/dive
CI/CD with GitHub Actions
- GitHub Actions Documentation: https://docs.github.com/en/actions
- Rust Action: https://github.com/dtolnay/rust-toolchain
- Docker Build and Push Action: https://github.com/docker/build-push-action
Monitoring and Observability
- Prometheus Documentation: https://prometheus.io/docs/
- Grafana Documentation: https://grafana.com/docs/
- “Observability Engineering” (O’Reilly): Book on observability patterns
Helpful Crates
prometheus: Prometheus metrics https://crates.io/crates/prometheustracing: Structured logging https://crates.io/crates/tracingtokio: Async runtime with graceful shutdown https://crates.io/crates/tokioaxum: Web framework with middleware support https://crates.io/crates/axum
Alternative Technologies
- Kubernetes: Container orchestration https://kubernetes.io/
- GitLab CI/CD: Alternative CI/CD platform https://docs.gitlab.com/ee/ci/
- DataDog: Commercial monitoring https://www.datadoghq.com/
- New Relic: Application performance monitoring https://newrelic.com/
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:
- Efficient Docker images - Multi-stage builds minimize size
- Automated testing - CI/CD catches issues early
- Graceful deployment - Health checks and shutdown handling
- Comprehensive monitoring - Prometheus + Grafana visibility
- Structured logging - Debugging and troubleshooting
These practices ensure your Rust applications are reliable, observable, and maintainable in production.
- Advanced container orchestration
- Building REST APIs with Axum - Web service foundations
- Async Error Handling in Rust - Robust error handling
- Error Handling Patterns in Rust Web Services - Production patterns
Deploy with confidence! 🚀
Comments