Actix Web #

Actix Web is the most mature, high-performance web framework in the Rust ecosystem. Based on the TechEmpower benchmarks, Actix Web consistently ranks at the top for HTTP throughput — even faster than Axum, which is itself very fast. Actix uses its own actor-model runtime (actix-rt) on top of Tokio, focusing on stability, type safety, and ergonomics.

Docker Compose complements Actix significantly because Rust compile times are heavy and native dependencies (OpenSSL, libpq) often conflict. This article covers a Docker Compose setup for Actix Web local development, from multi-stage Dockerfiles, cargo-watch hot reload, to database integration and best practices.

Prerequisites #

Make sure you have installed:

  • Docker and Docker Compose (latest versions)
  • The Rust toolchain (optional, for host-side development) — install via rustup
  • Git

A standard Actix project structure:

my-actix-app/
├── src/
│   ├── main.rs
│   ├── lib.rs
│   ├── config.rs
│   ├── db/
│   │   ├── mod.rs
│   │   └── pool.rs
│   ├── handlers/
│   │   ├── mod.rs
│   │   ├── health.rs
│   │   └── users.rs
│   ├── models/
│   │   ├── mod.rs
│   │   └── user.rs
│   ├── services/
│   │   ├── mod.rs
│   │   └── user_service.rs
│   └── errors.rs
├── migrations/
│   └── 001_create_users.sql
├── Cargo.toml
├── Cargo.lock
├── Dockerfile
├── Dockerfile.dev
├── docker-compose.yml
├── .env
├── .dockerignore
└── .cargo/
    └── config.toml

Multi-Stage Dockerfile #

Actix Web follows the same Rust Dockerfile pattern: a dummy build to cache dependencies, the real build for the binary, and a slim runtime for the final image.

# syntax=docker/dockerfile:1.6
FROM rust:1.75-slim AS builder

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
    pkg-config \
    libssl-dev \
    ca-certificates \
    && rm -rf /var/lib/apt/lists/*

# Cache dependencies with a dummy build
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs \
    && cargo build --release \
    && rm -rf src target/release/deps/actix_app*

# Real build
COPY . .
RUN cargo build --release

# Second stage: a slim runtime
FROM debian:12-slim

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

WORKDIR /app

RUN addgroup -S app && adduser -S app -G app
USER app:app

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

EXPOSE 8080

ENTRYPOINT ["/app/actix-app"]

A Development Dockerfile #

# Dockerfile.dev
FROM rust:1.75-slim

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
    pkg-config \
    libssl-dev \
    ca-certificates \
    git \
    curl \
    && rm -rf /var/lib/apt/lists/*

# Install cargo-watch for hot reload
RUN cargo install cargo-watch

# Cache dependencies
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs \
    && cargo fetch

EXPOSE 8080

CMD ["cargo", "watch", "-x", "run"]

docker-compose.yml #

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile.dev
    image: actix-app:dev
    container_name: actix-api
    command: cargo watch -x run
    volumes:
      - ./src:/app/src
      - ./Cargo.toml:/app/Cargo.toml
      - ./Cargo.lock:/app/Cargo.lock
      - cargo-registry:/usr/local/cargo/registry
      - cargo-target:/app/target
    ports:
      - "8080:8080"
    environment:
      - RUST_LOG=debug
      - DATABASE_URL=postgres://app:pass@db:5432/actixapp
      - REDIS_URL=redis://cache:6379/0
      - JWT_SECRET=local-dev-secret-change-me
      - SERVER_HOST=0.0.0.0
      - SERVER_PORT=8080
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    container_name: actix-db
    environment:
      - POSTGRES_USER=app
      - POSTGRES_PASSWORD=dev
      - POSTGRES_DB=actixapp
    volumes:
      - db-data:/var/lib/postgresql/data
      - ./db/init:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d actixapp"]
      interval: 10s
      timeout: 5s
      retries: 5
    ports:
      - "5432:5432"

  cache:
    image: redis:7-alpine
    container_name: actix-cache
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3
    volumes:
      - cache-data:/data
    ports:
      - "6379:6379"

volumes:
  db-data:
  cache-data:
  cargo-registry:
  cargo-target:

An Example Actix Application #

Cargo.toml:

[package]
name = "actix-app"
version = "0.1.0"
edition = "2021"

[dependencies]
actix-web = "4.5"
actix-rt = "2.9"

tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }
futures = "0.3"

sqlx = { version = "0.7", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "macros"] }

redis = { version = "0.25", features = ["tokio-comp", "connection-manager"] }

serde = { version = "1", features = ["derive"] }
serde_json = "1"

uuid = { version = "1", features = ["serde", "v4"] }
chrono = { version = "0.4", features = ["serde"] }

tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-actix-web = "0.7"

thiserror = "1"
anyhow = "1"

bcrypt = "0.15"
jsonwebtoken = "9"

dotenvy = "0.15"
config = "0.14"

src/config.rs:

use serde::Deserialize;

#[derive(Debug, Clone, Deserialize)]
pub struct Config {
    pub server: ServerConfig,
    pub database: DatabaseConfig,
    pub redis: RedisConfig,
    pub jwt_secret: String,
}

#[derive(Debug, Clone, Deserialize)]
pub struct ServerConfig {
    pub host: String,
    pub port: u16,
    pub workers: usize,
}

#[derive(Debug, Clone, Deserialize)]
pub struct DatabaseConfig {
    pub url: String,
    pub max_connections: u32,
}

#[derive(Debug, Clone, Deserialize)]
pub struct RedisConfig {
    pub url: String,
}

impl Config {
    pub fn from_env() -> anyhow::Result<Self> {
        let _ = dotenvy::dotenv();

        let host = std::env::var("SERVER_HOST").unwrap_or_else(|_| "0.0.0.0".into());
        let port = std::env::var("SERVER_PORT")
            .unwrap_or_else(|_| "8080".into())
            .parse()?;

        Ok(Self {
            server: ServerConfig {
                host,
                port,
                workers: std::env::var("SERVER_WORKERS")
                    .ok()
                    .and_then(|s| s.parse().ok())
                    .unwrap_or(2),
            },
            database: DatabaseConfig {
                url: std::env::var("DATABASE_URL")?,
                max_connections: std::env::var("DATABASE_MAX_CONNECTIONS")
                    .ok()
                    .and_then(|s| s.parse().ok())
                    .unwrap_or(10),
            },
            redis: RedisConfig {
                url: std::env::var("REDIS_URL")?,
            },
            jwt_secret: std::env::var("JWT_SECRET")?,
        })
    }
}

src/errors.rs:

use actix_web::{http::StatusCode, HttpResponse, ResponseError};
use serde_json::json;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum AppError {
    #[error("not found")]
    NotFound,

    #[error("validation error: {0}")]
    Validation(String),

    #[error("unauthorized")]
    Unauthorized,

    #[error("conflict: {0}")]
    Conflict(String),

    #[error("database error: {0}")]
    Database(#[from] sqlx::Error),

    #[error("redis error: {0}")]
    Redis(#[from] redis::RedisError),

    #[error("jwt error: {0}")]
    Jwt(#[from] jsonwebtoken::errors::Error),

    #[error("bcrypt error: {0}")]
    Bcrypt(#[from] bcrypt::BcryptError),

    #[error("config error: {0}")]
    Config(#[from] anyhow::Error),

    #[error("internal error: {0}")]
    Internal(String),
}

impl ResponseError for AppError {
    fn status_code(&self) -> StatusCode {
        match self {
            AppError::NotFound => StatusCode::NOT_FOUND,
            AppError::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
            AppError::Unauthorized => StatusCode::UNAUTHORIZED,
            AppError::Conflict(_) => StatusCode::CONFLICT,
            _ => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }

    fn error_response(&self) -> HttpResponse {
        let status = self.status_code();
        let message = match self {
            AppError::NotFound => "not found".to_string(),
            AppError::Validation(msg) => msg.clone(),
            AppError::Unauthorized => "unauthorized".to_string(),
            AppError::Conflict(msg) => msg.clone(),
            _ => {
                tracing::error!(error = %self, "internal error");
                "internal server error".to_string()
            }
        };

        HttpResponse::build(status).json(json!({ "error": message }))
    }
}

pub type AppResult<T> = Result<T, AppError>;

src/db/mod.rs:

use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use std::time::Duration;

use crate::config::Config;

pub async fn create_pool(config: &Config) -> anyhow::Result<PgPool> {
    let pool = PgPoolOptions::new()
        .max_connections(config.database.max_connections)
        .acquire_timeout(Duration::from_secs(5))
        .connect(&config.database.url)
        .await?;

    sqlx::migrate!("./migrations").run(&pool).await?;
    Ok(pool)
}

src/models/user.rs:

use chrono::{DateTime, Utc};
use uuid::Uuid;

#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize)]
pub struct User {
    pub id: Uuid,
    pub email: String,
    pub name: String,
    #[serde(skip_serializing)]
    pub password_hash: String,
    pub is_verified: bool,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

impl User {
    pub fn to_response(&self) -> UserResponse {
        UserResponse {
            id: self.id,
            email: self.email.clone(),
            name: self.name.clone(),
            is_verified: self.is_verified,
            created_at: self.created_at,
        }
    }
}

#[derive(Debug, Clone, serde::Serialize)]
pub struct UserResponse {
    pub id: Uuid,
    pub email: String,
    pub name: String,
    pub is_verified: bool,
    pub created_at: DateTime<Utc>,
}

src/services/user_service.rs:

use sqlx::PgPool;
use uuid::Uuid;

use crate::errors::{AppError, AppResult};
use crate::models::user::{User, UserResponse};

pub struct UserService {
    db: PgPool,
}

impl UserService {
    pub fn new(db: PgPool) -> Self {
        Self { db }
    }

    pub async fn create(
        &self,
        email: String,
        name: String,
        password: String,
    ) -> AppResult<UserResponse> {
        if password.len() < 8 {
            return Err(AppError::Validation("password too short".into()));
        }
        if !email.contains('@') {
            return Err(AppError::Validation("invalid email".into()));
        }

        let hash = bcrypt::hash(&password, bcrypt::DEFAULT_COST)?;

        let user = sqlx::query_as::<_, User>(
            r#"
            INSERT INTO users (email, name, password_hash)
            VALUES ($1, $2, $3)
            RETURNING id, email, name, password_hash, is_verified, created_at, updated_at
            "#,
        )
        .bind(email.to_lowercase())
        .bind(name)
        .bind(hash)
        .fetch_one(&self.db)
        .await
        .map_err(|e| match e {
            sqlx::Error::Database(db_err) if db_err.constraint() == Some("users_email_key") => {
                AppError::Conflict("email already registered".into())
            }
            other => AppError::Database(other),
        })?;

        Ok(user.to_response())
    }

    pub async fn find_by_id(&self, id: Uuid) -> AppResult<Option<UserResponse>> {
        let user = sqlx::query_as::<_, User>(
            r#"SELECT id, email, name, password_hash, is_verified, created_at, updated_at
               FROM users WHERE id = $1"#,
        )
        .bind(id)
        .fetch_optional(&self.db)
        .await?;

        Ok(user.map(|u| u.to_response()))
    }

    pub async fn list(&self, limit: i64, offset: i64) -> AppResult<Vec<UserResponse>> {
        let users = sqlx::query_as::<_, User>(
            r#"SELECT id, email, name, password_hash, is_verified, created_at, updated_at
               FROM users
               ORDER BY created_at DESC
               LIMIT $1 OFFSET $2"#,
        )
        .bind(limit)
        .bind(offset)
        .fetch_all(&self.db)
        .await?;

        Ok(users.iter().map(|u| u.to_response()).collect())
    }

    pub async fn update(
        &self,
        id: Uuid,
        name: Option<String>,
        is_verified: Option<bool>,
    ) -> AppResult<Option<UserResponse>> {
        let user = sqlx::query_as::<_, User>(
            r#"
            UPDATE users
            SET name = COALESCE($2, name),
                is_verified = COALESCE($3, is_verified),
                updated_at = NOW()
            WHERE id = $1
            RETURNING id, email, name, password_hash, is_verified, created_at, updated_at
            "#,
        )
        .bind(id)
        .bind(name)
        .bind(is_verified)
        .fetch_optional(&self.db)
        .await?;

        Ok(user.map(|u| u.to_response()))
    }

    pub async fn delete(&self, id: Uuid) -> AppResult<bool> {
        let result = sqlx::query("DELETE FROM users WHERE id = $1")
            .bind(id)
            .execute(&self.db)
            .await?;
        Ok(result.rows_affected() > 0)
    }
}

src/handlers/users.rs:

use actix_web::{delete, get, post, put, web, HttpResponse};
use serde::Deserialize;
use uuid::Uuid;

use crate::errors::AppResult;
use crate::services::user_service::UserService;

#[derive(Debug, Deserialize)]
pub struct CreateUserBody {
    pub email: String,
    pub name: String,
    pub password: String,
}

#[derive(Debug, Deserialize)]
pub struct UpdateUserBody {
    pub name: Option<String>,
    pub is_verified: Option<bool>,
}

#[derive(Debug, Deserialize)]
pub struct ListQuery {
    pub limit: Option<i64>,
    pub offset: Option<i64>,
}

pub fn config(cfg: &mut web::ServiceConfig) {
    cfg.service(create_user)
        .service(get_user)
        .service(list_users)
        .service(update_user)
        .service(delete_user);
}

#[post("")]
pub async fn create_user(
    body: web::Json<CreateUserBody>,
    service: web::Data<UserService>,
) -> AppResult<HttpResponse> {
    let user = service
        .create(body.email.clone(), body.name.clone(), body.password.clone())
        .await?;
    Ok(HttpResponse::Created().json(user))
}

#[get("/{id}")]
pub async fn get_user(
    path: web::Path<Uuid>,
    service: web::Data<UserService>,
) -> AppResult<HttpResponse> {
    let id = path.into_inner();
    let user = service.find_by_id(id).await?;
    match user {
        Some(u) => Ok(HttpResponse::Ok().json(u)),
        None => Err(crate::errors::AppError::NotFound),
    }
}

#[get("")]
pub async fn list_users(
    query: web::Query<ListQuery>,
    service: web::Data<UserService>,
) -> AppResult<HttpResponse> {
    let limit = query.limit.unwrap_or(20).min(100);
    let offset = query.offset.unwrap_or(0);
    let users = service.list(limit, offset).await?;
    Ok(HttpResponse::Ok().json(serde_json::json!({
        "data": users,
        "limit": limit,
        "offset": offset,
    })))
}

#[put("/{id}")]
pub async fn update_user(
    path: web::Path<Uuid>,
    body: web::Json<UpdateUserBody>,
    service: web::Data<UserService>,
) -> AppResult<HttpResponse> {
    let id = path.into_inner();
    let user = service
        .update(id, body.name.clone(), body.is_verified)
        .await?;
    match user {
        Some(u) => Ok(HttpResponse::Ok().json(u)),
        None => Err(crate::errors::AppError::NotFound),
    }
}

#[delete("/{id}")]
pub async fn delete_user(
    path: web::Path<Uuid>,
    service: web::Data<UserService>,
) -> AppResult<HttpResponse> {
    let id = path.into_inner();
    let deleted = service.delete(id).await?;
    if deleted {
        Ok(HttpResponse::NoContent().finish())
    } else {
        Err(crate::errors::AppError::NotFound)
    }
}

src/handlers/health.rs:

use actix_web::{get, web, HttpResponse};
use redis::aio::ConnectionManager;
use sqlx::PgPool;

use crate::errors::AppResult;

#[get("/healthz")]
pub async fn healthz() -> HttpResponse {
    HttpResponse::Ok().json(serde_json::json!({"status": "ok"}))
}

#[get("/readyz")]
pub async fn readyz(
    db: web::Data<PgPool>,
    redis: web::Data<ConnectionManager>,
) -> AppResult<HttpResponse> {
    let db_ok = sqlx::query("SELECT 1").execute(db.get_ref()).await.is_ok();
    let redis_ok = {
        let mut conn = redis.get_ref().clone();
        redis::cmd("PING")
            .query_async::<_, String>(&mut conn)
            .await
            .is_ok()
    };

    let status = if db_ok && redis_ok { "ready" } else { "degraded" };
    let code = if db_ok && redis_ok {
        actix_web::http::StatusCode::OK
    } else {
        actix_web::http::StatusCode::SERVICE_UNAVAILABLE
    };

    Ok(HttpResponse::build(code).json(serde_json::json!({
        "status": status,
        "database": if db_ok { "up" } else { "down" },
        "cache": if redis_ok { "up" } else { "down" },
    })))
}

src/main.rs:

use actix_web::{middleware as actix_middleware, web, App, HttpServer};
use tracing_actix_web::TracingLogger;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

mod config;
mod db;
mod errors;
mod handlers;
mod models;
mod services;

use crate::config::Config;
use crate::services::user_service::UserService;

#[actix_web::main]
async fn main() -> anyhow::Result<()> {
    // Logging
    tracing_subscriber::registry()
        .with(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "actix_app=debug,actix_web=info,info".into()),
        )
        .with(tracing_subscriber::fmt::layer())
        .init();

    // Load config
    let config = Config::from_env()?;
    tracing::info!("loaded configuration");

    // Database
    let db_pool = db::create_pool(&config).await?;
    tracing::info!("database pool ready");

    // Redis
    let redis_client = redis::Client::open(config.redis.url.clone())?;
    let redis_manager = redis::aio::ConnectionManager::new(redis_client).await?;
    tracing::info!("redis connected");

    // Services
    let user_service = web::Data::new(UserService::new(db_pool.clone()));
    let db_data = web::Data::new(db_pool);
    let redis_data = web::Data::new(redis_manager);

    // Server
    let bind_addr = format!("{}:{}", config.server.host, config.server.port);
    tracing::info!("starting server on {}", bind_addr);

    HttpServer::new(move || {
        App::new()
            .app_data(user_service.clone())
            .app_data(db_data.clone())
            .app_data(redis_data.clone())
            .app_data(web::JsonConfig::default().error_handler(|err, _req| {
                actix_web::error::InternalError::from_response(
                    "",
                    actix_web::HttpResponse::BadRequest().json(serde_json::json!({
                        "error": format!("invalid JSON: {}", err)
                    })),
                )
                .into()
            }))
            .wrap(TracingLogger::default())
            .wrap(actix_middleware::Compress::default())
            .service(
                web::scope("")
                    .service(handlers::health::healthz)
                    .service(handlers::health::readyz)
                    .service(
                        web::scope("/api/v1/users")
                            .configure(handlers::users::config),
                    ),
            )
    })
    .workers(config.server.workers)
    .bind(&bind_addr)?
    .run()
    .await?;

    Ok(())
}

Migrations #

migrations/20240101000000_create_users.sql:

CREATE TABLE IF NOT EXISTS users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email VARCHAR(255) NOT NULL UNIQUE,
    name VARCHAR(255) NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    is_verified BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_users_created_at ON users(created_at);

sqlx::migrate!("./migrations") in db::create_pool automatically applies migrations at startup.

Actix’s Built-in Middleware #

Actix Web has built-in middleware you can use directly.

MiddlewareFunction
LoggerLog every request
CompressResponse compression (gzip, brotli)
DefaultHeadersSet default response headers
CorsHandle CORS (needs the actix-cors crate)
ErrorHandlersCustom error responses
NormalizePathNormalize URL paths (trailing slash)
RedirectToSlashRedirect to trailing slash
use actix_web::middleware::{Logger, Compress, DefaultHeaders, NormalizePath};

App::new()
    .wrap(Logger::default())
    .wrap(Compress::default())
    .wrap(DefaultHeaders::new().add(("X-Version", "1.0")))
    .wrap(NormalizePath::trim())

Actix-Web vs Axum #

Before choosing Actix, understand the trade-offs:

AspectActix WebAxum
Runtimeactix-rt (Tokio-based)Native Tokio
ThroughputSlightly higherVery high
EcosystemOlder, more librariesNewer, modern ecosystem
Middlewareactix-web::dev traittower Service
StreamingFirst-classNeeds an extra crate
WebSocketBuilt-in (actix-web-actors)Built-in (axum::extract::ws)
GraphQLasync-graphqlasync-graphql
Builder patternErgonomicCompositional

For most cases, the performance difference isn’t noticeable. Choose Axum for Tokio ecosystem interoperability. Choose Actix for a more mature library ecosystem and a larger community.

Build and Run #

# Build
docker compose build

# Run
docker compose up -d

# View logs
docker compose logs -f api

# Stop
docker compose down

Access:

  • API: http://localhost:8080
  • Health: http://localhost:8080/healthz
  • Ready: http://localhost:8080/readyz
  • PostgreSQL: localhost:5432
  • Redis: localhost:6379

Test:

# Create a user
curl -X POST http://localhost:8080/api/v1/users \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","name":"Hadi","password":"password123"}'

# Get a user
curl http://localhost:8080/api/v1/users/<uuid>

# List users
curl "http://localhost:8080/api/v1/users?limit=10&offset=0"

When Actix Fits #

Use Actix Web if:
  ✓ High-performance microservices with maximum throughput
  ✓ You need a broad library ecosystem
  ✓ Apps with many WebSocket connections
  ✓ Streaming HTTP responses (SSE, chunked)
  ✓ A Rust team needing a mature framework

Avoid Actix Web if:
  ✗ You only need standard CRUD APIs (Axum/Gin is simpler)
  ✗ You need interoperability with non-Tokio libraries
  ✗ Compile times are a blocker
  ✗ A team new to Rust (Axum is more idiomatic)

Actix Web is the right choice for services needing high performance and a broad library ecosystem. For simpler services, Axum or Gin fits better.

Best Practices #

Cache Cargo with Volumes #

Rust compile times are heavy. The cargo-target and cargo-registry volumes are mandatory for cross-restart caching.

Dummy Builds for Layer Caching #

The mkdir src && echo "fn main() {}" > src/main.rs && cargo build pattern caches dependencies in a Docker layer.

Use cargo-watch #

cargo watch -x run watches .rs files and rebuilds + restarts. Essential DX for Rust developers.

The Service Layer Pattern #

Separate business logic from handlers. Handlers only orchestrate HTTP. Service classes contain business logic and database operations.

AppError with ResponseError #

Implement ResponseError for custom errors. The From trait auto-converts from sqlx::Error, bcrypt::BcryptError, etc.

Bind to 0.0.0.0 #

Important for Docker. HttpServer::bind("0.0.0.0:8080") so it’s reachable from the host.

Multi-Worker Actix #

HttpServer::new(...).workers(n) sets the worker process count. Default is 1; raise it for concurrent requests.

Connection Pools #

sqlx::PgPool with max_connections per load. redis::aio::ConnectionManager for Redis (built-in auto-reconnect).

SQLx for Compile-time Checks #

The sqlx::query! macro validates queries at compile time. Needs DATABASE_URL in the build environment, or sqlx prepare to generate an offline cache.

Composable Middleware #

Actix middleware uses .wrap(). Order matters — wrap(A).wrap(B) means B executes first.

Troubleshooting #

Very Slow Cargo Builds #

Make sure the cargo-target volume is mounted. Check that the cargo fetch cache works. Use sccache for compile caching.

Database Connection Refused #

Use retry logic in PgPool::connect. depends_on: condition: service_healthy in Compose.

Port 8080 Already in Use #

lsof -i :8080

Hot Reload Not Detecting #

Make sure cargo-watch is installed. Raise the delay in the configuration. Check whether include_dir covers the source directory.

SQLx “No such table” at Compile Time #

Set DATABASE_URL during compilation, or generate an offline cache with cargo sqlx prepare.

Worker Process Limit #

Actix defaults to 1 worker. Raise it with .workers(4) for concurrency. Set SERVER_WORKERS via the environment.

Summary #

  • Actix Web is ideal for high-performance Rust services with the highest throughput in the Rust ecosystem.
  • Actix uses its own actix-rt runtime (on top of Tokio) — unlike Axum, which is native Tokio. Some non-Tokio libraries may need adapters.
  • Multi-stage Dockerfiles for production: a dummy build caches dependencies, the real build produces the binary. The final image is < 80 MB.
  • Dockerfile.dev for development: full toolchain + cargo-watch for hot reload.
  • Mandatory volumes for cargo-registry and cargo-target — Rust compile times are heavy, caching across container restarts is a must.
  • The dummy build pattern: mkdir src && echo "fn main() {}" > src/main.rs && cargo build caches the dependency layer.
  • Cargo-watch watches .rs files, rebuilds, and restarts automatically.
  • SQLx with compile-time checks: the sqlx::query! macro validates queries against the database schema at compile time.
  • Connection pools for Postgres (sqlx::PgPool) and Redis (ConnectionManager).
  • AppError with ResponseError: an enum implementing actix_web::ResponseError, plus From traits for auto-conversion.
  • Built-in middleware: Logger, Compress, DefaultHeaders, NormalizePath, ErrorHandlers. Compose with .wrap().
  • The service layer pattern: separate business logic from handlers. Services contain database operations.
  • Multi-worker with .workers(n). Default 1; raise it for concurrent requests.
  • Bind to 0.0.0.0 — important for Docker.
  • tracing-actix-web for automatic HTTP tracing. Integration with Jaeger/Honeycomb.
  • Best practices: cache Cargo, dummy builds, cargo-watch, service layers, AppError, binding 0.0.0.0, multi-worker, connection pools.
  • Use Actix for Rust services needing the highest performance. Avoid it for CRUD-centric business apps (overkill).
  • Alternatives: Axum for idiomatic Tokio, Rocket for ergonomic developer experience, warp for low-level control.

← Previous: Axum   Next: Rocket →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact