Cómo construir un servicio web en Rust con Actix-web y SQLx paso a paso
En este tutorial práctico construimos desde cero una API REST simple en Rust usando actix-web y sqlx (Postgres). Verás estructura de proyecto, código completo, migraciones SQL, y cómo ejecutar todo con Docker y variables de entorno.
Qué vas a aprender
- Configurar proyecto Cargo para web + async DB
- Conexión segura con
PgPoolde sqlx - Crear handlers, modelos y rutas
- Escribir y aplicar migrations
- Probar endpoints con curl
Requisitos
- Rust (stable)
- cargo
- Docker (para Postgres) o Postgres local
- sqlx-cli (opcional, para migraciones)
Estructura de carpetas
rust-actix-sqlx/
├── Cargo.toml
├── .env
├── migrations/
│ └── 20260701_create_users_table.sql
└── src/
├── main.rs
├── db.rs
├── handlers.rs
└── models.rs
Cargo.toml
[package]
name = "rust-actix-sqlx"
version = "0.1.0"
edition = "2021"
[dependencies]
actix-web = "4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "macros"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
uuid = { version = "1", features = ["v4"] }
env_logger = "0.10"
dotenvy = "0.15"
.env (en la raíz)
DATABASE_URL=postgres://postgres:postgres@localhost:5432/rust_api
Usamos dotenvy para cargar variables de entorno localmente. Nunca subas .env a repos públicos.
Migración: migrations/20260701_create_users_table.sql
-- Up
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
-- Down
DROP TABLE IF EXISTS users;
src/models.rs
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Serialize)]
pub struct User {
pub id: Uuid,
pub username: String,
pub email: String,
pub created_at: chrono::DateTime,
}
#[derive(Deserialize)]
pub struct CreateUser {
pub username: String,
pub email: String,
}
src/db.rs
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use std::time::Duration;
pub async fn create_pool(database_url: &str) -> anyhow::Result {
let pool = PgPoolOptions::new()
.max_connections(5)
.connect_timeout(Duration::from_secs(5))
.connect(database_url)
.await?;
Ok(pool)
}
src/handlers.rs
use actix_web::{get, post, web, HttpResponse, Responder};
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::{CreateUser, User};
#[post("/users")]
pub async fn create_user(db: web::Data, payload: web::Json) -> impl Responder {
let id = Uuid::new_v4();
let res = sqlx::query_as!(
// mapping to struct needs exact column names
User,
r#"
INSERT INTO users (id, username, email)
VALUES ($1, $2, $3)
RETURNING id, username, email, created_at
"#,
id,
payload.username,
payload.email
)
.fetch_one(db.get_ref())
.await;
match res {
Ok(user) => HttpResponse::Created().json(user),
Err(e) => {
// not leaking internals; in prod map errors properly
HttpResponse::InternalServerError().body(format!("DB error: {}", e))
}
}
}
#[get("/users/{id}")]
pub async fn get_user(db: web::Data, path: web::Path) -> impl Responder {
let id = path.into_inner();
let res = sqlx::query_as!(
User,
r#"SELECT id, username, email, created_at FROM users WHERE id = $1"#,
id
)
.fetch_one(db.get_ref())
.await;
match res {
Ok(user) => HttpResponse::Ok().json(user),
Err(sqlx::Error::RowNotFound) => HttpResponse::NotFound().finish(),
Err(e) => HttpResponse::InternalServerError().body(format!("DB error: {}", e)),
}
}
src/main.rs
mod db;
mod handlers;
mod models;
use actix_web::{middleware::Logger, web, App, HttpServer};
use dotenvy::dotenv;
use std::env;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
dotenv().ok();
env_logger::init();
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = db::create_pool(&database_url)
.await
.expect("Failed to create DB pool");
let bind = "127.0.0.1:8080";
println!("Listening on http://{}", bind);
HttpServer::new(move || {
App::new()
.wrap(Logger::default())
.app_data(web::Data::new(pool.clone()))
.service(handlers::create_user)
.service(handlers::get_user)
})
.bind(bind)?
.run()
.await
}
Aplicar migraciones
Opción A: usar sqlx-cli (recomendado durante desarrollo):
cargo install sqlx-cli --no-default-features --features postgres
export DATABASE_URL=postgres://postgres:postgres@localhost:5432/rust_api
sqlx migrate add create_users_table # si quieres generar archivo
sqlx migrate run
Opción B: ejecutar el SQL manualmente contra tu base de datos (psql o cliente)
Levantar Postgres con Docker Compose
version: '3.8'
services:
db:
image: postgres:15
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: rust_api
ports:
- "5432:5432"
volumes:
- db_data:/var/lib/postgresql/data
volumes:
db_data:
Comandos para probar
# 1) Levanta DB
docker compose up -d
# 2) Aplica migración (sqlx-cli o psql)
# si usas sqlx-cli:
export DATABASE_URL=postgres://postgres:postgres@localhost:5432/rust_api
sqlx migrate run
# 3) Ejecuta la app
cargo run
# 4) Crear usuario
curl -X POST -H "Content-Type: application/json" \
-d '{"username":"alice","email":"alice@example.com"}' \
http://127.0.0.1:8080/users
# 5) Obtener usuario (reemplaza UUID real)
curl http://127.0.0.1:8080/users/
Por qué esta arquitectura
- actix-web: alto rendimiento y ergonomía para handlers async.
- sqlx con macros: queries tipeadas en tiempo de compilación si corres
DATABASE_URLdurante build o usascargo sqlx prepare. - Separación en
db,handlers,modelsmantiene el código testeable y claro.
Buenas prácticas y notas importantes
- Usa
sqlx::query_as!para asegurarte de que las columnas devueltas mapeen exactamente a tu struct. - Maneja errores de forma explícita: transforma errores de SQL en respuestas HTTP apropiadas.
- Configura límites de conexiones según la carga y tamaño de la base de datos.
- No ejecutes
sqlx::query!con strings concatenados; usa parámetros para evitar inyección. - En producción, usa TLS entre app y DB y revisa timeouts y retry policies.
Optimización rápida
Si tu app crece, considera:
- Incrementar
max_connectionso usar PgBouncer para multiplexado. - Usar prepared statements y cachear consultas frecuentes.
- Agregar métricas (Prometheus) y tracing (opentelemetry + tracing).
Próximos pasos sugeridos
Agregar autenticación (JWT), validación de entrada con validator, paginación para endpoints de lista y pruebas (integration tests usando Testcontainers o una DB en memoria). Un paso avanzado: integrar sqlx offline mode para garantizar que tus queries sigan tipeadas sin exponer la cadena de conexión en CI.
Consejo avanzado: habilita las comprobaciones de SQL en tiempo de compilación con cargo sqlx prepare -- --lib en tu pipeline CI y usa variables de entorno seguras para la cadena de conexión; esto evita regresiones silenciosas en queries.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación