Actix Web #
Actix Web adalah framework web paling mature dan berperforma tinggi di ekosistem Rust. Berdasarkan TechEmpower benchmark, Actix Web secara konsisten menempati posisi teratas untuk throughput HTTP — bahkan lebih cepat dari Axum yang juga sangat cepat. Actix menggunakan runtime actor model sendiri (actix-rt) di atas Tokio, dengan fokus pada stabilitas, keamanan tipe, dan ergonomi.
Docker Compose melengkapi Actix dengan cara yang signifikan karena Rust compile time berat dan dependency native (OpenSSL, libpq) sering bentrok. Artikel ini membahas setup Docker Compose untuk local development Actix Web, dari Dockerfile multi-stage, hot reload dengan cargo-watch, hingga integrasi database dan best practice.
Prasyarat #
Pastikan sudah terinstall:
- Docker dan Docker Compose versi terbaru
- Rust toolchain (opsional, untuk development di host) — install via
rustup - Git
Struktur project Actix standar:
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
Dockerfile Multi-Stage #
Actix Web mengikuti pattern Rust Dockerfile yang sama: dummy build untuk cache dependency, build asli untuk binary, runtime ramping untuk image akhir.
# 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 dengan 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
# Stage kedua: runtime ramping
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"]
Dockerfile untuk Development #
# 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 untuk 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:dev@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:
Contoh Aplikasi Actix #
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(())
}
Migration #
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") di db::create_pool otomatis apply migration saat startup.
Middleware Bawaan Actix #
Actix Web punya middleware built-in yang bisa dipakai langsung.
| Middleware | Fungsi |
|---|---|
Logger |
Log setiap request |
Compress |
Kompresi response (gzip, brotli) |
DefaultHeaders |
Set default response header |
Cors |
Handle CORS (perlu crate actix-cors) |
ErrorHandlers |
Custom error response |
NormalizePath |
Normalize URL path (trailing slash) |
RedirectToSlash |
Redirect ke 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 #
Sebelum pilih Actix, pahami trade-off-nya:
| Aspek | Actix Web | Axum |
|---|---|---|
| Runtime | actix-rt (Tokio-based) |
Tokio native |
| Throughput | Sedikit lebih tinggi | Sangat tinggi |
| Ekosistem | Lebih tua, lebih banyak library | Lebih baru, ekosistem modern |
| Middleware | actix-web::dev trait |
tower Service |
| Streaming | First-class | Perlu extra crate |
| WebSocket | Built-in (actix-web-actors) |
Built-in (axum::extract::ws) |
| GraphQL | async-graphql |
async-graphql |
| Builder pattern | Ergonomis | Compositional |
Untuk kebanyakan kasus, perbedaan performa tidak terasa. Pilih Axum untuk interoperabilitas dengan Tokio ecosystem. Pilih Actix untuk library ecosystem yang lebih matang dan komunitas yang lebih besar.
Build dan Run #
# Build
docker compose build
# Jalankan
docker compose up -d
# Lihat log
docker compose logs -f api
# Stop
docker compose down
Akses:
- API: http://localhost:8080
- Health: http://localhost:8080/healthz
- Ready: http://localhost:8080/readyz
- PostgreSQL:
localhost:5432 - Redis:
localhost:6379
Test:
# Create user
curl -X POST http://localhost:8080/api/v1/users \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","name":"Hadi","password":"password123"}'
# Get user
curl http://localhost:8080/api/v1/users/<uuid>
# List users
curl "http://localhost:8080/api/v1/users?limit=10&offset=0"
Kapan Actix Tepat #
Pakai Actix Web jika:
✓ Microservice high-performance dengan throughput maksimal
✓ Butuh library ecosystem yang luas
✓ Aplikasi dengan banyak WebSocket connection
✓ Streaming HTTP response (SSE, chunked)
✓ Tim Rust yang butuh framework mature
Hindari Actix Web jika:
✗ Hanya butuh API CRUD standar (Axum/Gin lebih simpel)
✗ Butuh interoperabilitas dengan non-Tokio library
✗ Compile time menjadi blocker
✗ Tim baru di Rust (Axum lebih idiomatic)
Actix Web adalah pilihan tepat untuk service yang butuh performa tinggi dan ekosistem library yang luas. Untuk service yang lebih sederhana, Axum atau Gin lebih cocok.
Best Practice #
Cache Cargo dengan Volume #
Rust compile time berat. Volume cargo-target dan cargo-registry wajib untuk cache antar restart.
Dummy Build untuk Cache Layer #
Pattern mkdir src && echo "fn main() {}" > src/main.rs && cargo build memungkinkan cache dependency di Docker layer.
Pakai cargo-watch #
cargo watch -x run memantau file .rs dan rebuild + restart. DX yang sangat dibutuhkan Rust developer.
Service Layer Pattern #
Pisahkan business logic dari handler. Handler cuma orchestrasi HTTP. Service class berisi business logic dan database operation.
AppError dengan ResponseError #
Implement ResponseError untuk custom error. Pattern From trait untuk auto-convert dari sqlx::Error, bcrypt::BcryptError, dll.
Bind ke 0.0.0.0 #
Penting untuk Docker. HttpServer::bind("0.0.0.0:8080") agar bisa diakses dari host.
Multi-worker Actix #
HttpServer::new(...).workers(n) set jumlah worker process. Default 1, naikkan untuk concurrent request.
Connection Pool #
sqlx::PgPool dengan max_connections sesuai beban. redis::aio::ConnectionManager untuk Redis (auto-reconnect built-in).
SQLx untuk Compile-time Check #
sqlx::query! macro validasi query saat compile. Butuh DATABASE_URL di environment build, atau sqlx prepare untuk generate offline cache.
Middleware Composable #
Actix middleware pakai .wrap(). Order penting — wrap(A).wrap(B) artinya B dieksekusi lebih dulu.
Troubleshooting #
Cargo Build Sangat Lambat #
Pastikan volume cargo-target di-mount. Cek apakah cargo fetch cache jalan. Pakai sccache untuk cache compile.
Database Connection Refused #
Pakai retry logic di PgPool::connect. depends_on: condition: service_healthy di Compose.
Port 8080 Sudah Dipakai #
lsof -i :8080
Hot Reload Tidak Detect #
Pastikan cargo-watch terinstall. Naikkan delay di konfigurasi. Cek include_dir mencakup direktori source.
SQLx “No such table” saat Compile #
Set DATABASE_URL saat compile, atau generate offline cache dengan cargo sqlx prepare.
Worker Process Limit #
Default Actix pakai 1 worker. Naikkan di .workers(4) untuk concurrent. Set SERVER_WORKERS via environment.
Ringkasan #
- Actix Web ideal untuk service Rust high-performance dengan throughput tertinggi di ekosistem Rust.
- Actix pakai runtime
actix-rtsendiri (di atas Tokio) — berbeda dari Axum yang native Tokio. Beberapa library non-Tokio mungkin perlu adaptor.- Dockerfile multi-stage untuk production: dummy build cache dependency, build asli untuk binary. Image akhir < 80 MB.
- Dockerfile.dev untuk development: full toolchain + cargo-watch untuk hot reload.
- Volume wajib untuk
cargo-registrydancargo-target— Rust compile time berat, cache antar restart container wajib.- Dummy build pattern:
mkdir src && echo "fn main() {}" > src/main.rs && cargo buildcache layer dependencies.- Cargo-watch memantau file
.rs, rebuild, restart otomatis.- SQLx dengan compile-time check:
sqlx::query!macro validasi query terhadap schema database saat compile.- Connection pool untuk Postgres (
sqlx::PgPool) dan Redis (ConnectionManager).- AppError dengan
ResponseError: enum yang implementactix_web::ResponseError, plusFromtrait untuk auto-convert.- Middleware built-in: Logger, Compress, DefaultHeaders, NormalizePath, ErrorHandlers. Compose dengan
.wrap().- Service layer pattern: pisahkan business logic dari handler. Service berisi database operation.
- Multi-worker dengan
.workers(n). Default 1, naikkan untuk concurrent request.- Bind ke
0.0.0.0— penting untuk Docker.- Tracing-actix-web untuk HTTP tracing otomatis. Integrasi dengan Jaeger/Honeycomb.
- Best practice: cache Cargo, dummy build, cargo-watch, service layer, AppError, bind 0.0.0.0, multi-worker, connection pool.
- Pakai Actix untuk service Rust yang butuh performa tertinggi. Hindari untuk aplikasi bisnis CRUD-sentris (overkill).
- Alternatif: Axum untuk idiomatic Tokio, Rocket untuk developer experience ergonomis, warp untuk low-level control.