Build a Small Web Service in Rust with PostgreSQL: A Modern Step-by-Step Guide

Build a Small Web Service in Rust with PostgreSQL: A Modern Step-by-Step Guide

September 3, 2026

Rust and PostgreSQL make an excellent stack for small web services that need to be fast, reliable, and maintainable. Rust gives you strong compile-time guarantees, predictable performance, and a growing async ecosystem. PostgreSQL gives you a mature relational database with rich indexing, transactional consistency, and a long track record in production. For teams building API-first services, this combination often hits the sweet spot between developer productivity and operational safety. Rust’s official book recommends installing via rustup, which keeps the toolchain current, and the modern async ecosystem is built around Tokio for runtime support. On the database side, PostgreSQL 18 introduced features such as asynchronous I/O and skip-scan support, which are especially relevant for workloads that benefit from better scan and index behavior. (doc.rust-lang.org)

In this guide, we’ll build a small REST-style service using Rust, Axum, SQLx, and PostgreSQL. We’ll cover project setup, schema design, migrations, request handling, database access, CRUD endpoints, configuration, testing, deployment, and performance hardening. The goal is not just to “get something working,” but to establish a practical template you can extend into a real production service. Along the way, we’ll use modern Rust idioms, async best practices, and database patterns that scale from local development to deployment. (docs.rs)

Service architecture overview

1. Introduction: Why Rust and PostgreSQL Are a Strong Stack for Small Web Services

Rust is a strong choice for web services because it combines low-level performance with high-level safety. Memory safety without a garbage collector reduces entire classes of bugs, while the type system helps catch many problems at compile time instead of in production. For small services, this matters because the codebase is usually compact but must still be robust enough to survive changing requirements, growing traffic, and evolving integrations. Rust’s async model, typically powered by Tokio, lets you handle many concurrent requests efficiently without tying up threads unnecessarily. Tokio’s runtime provides the I/O driver, scheduler, and timer infrastructure needed for async applications. (docs.rs)

PostgreSQL complements Rust well because it is a dependable relational database with excellent transactional semantics and a wide feature set. If your service stores user accounts, tasks, documents, payments metadata, or any other structured data, PostgreSQL gives you ACID guarantees, strong indexing options, and mature tooling. PostgreSQL 18 adds features that can improve sequential scans and bitmap heap scans via asynchronous I/O, and it improves index behavior with skip-scan lookups on multicolumn B-tree indexes. Those are not features you need to “hand-optimize” on day one, but they matter when your service grows and query patterns become more interesting. (postgresql.org)

The stack is especially compelling for small teams because it keeps the architecture straightforward. Axum provides a modular HTTP layer with minimal boilerplate, SQLx offers async database access, and PostgreSQL handles persistence without introducing a separate distributed data layer. The result is a service that is easy to reason about: requests come in, handlers validate them, the database layer executes typed queries, and responses go out as JSON. That simplicity is a real advantage when you want strong correctness without overengineering. (docs.rs)

2. Project Setup: Installing Rust, Choosing a Framework, and Creating the Service Scaffold

The recommended way to install Rust is via rustup, which manages toolchains and makes it easy to update or switch versions. The Rust Book’s installation guide explicitly points to rustup as the standard installer. Once installed, you can create a new project with cargo new, which gives you a conventional Rust workspace and dependency management through Cargo.toml. For a modern async web service, the most common combination is Tokio for the runtime and Axum for the HTTP framework. Axum is ergonomic, modular, and designed to work with Tokio and Hyper. (doc.rust-lang.org)

A good initial scaffold might look like this:

cargo new rust-postgres-service
cd rust-postgres-service

Then add dependencies that match the stack we’re building:

[package]
name = "rust-postgres-service"
version = "0.1.0"
edition = "2024"

[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["serde", "v4"] }
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
tower-http = { version = "0.6", features = ["trace", "cors"] }
dotenvy = "0.15"

That set gives you async runtime support, routing, PostgreSQL access, serialization, structured errors, and logging. SQLx is an async SQL toolkit for Rust, and its docs emphasize that you need at least one async runtime feature enabled for async APIs to work correctly. (docs.rs)

For a minimal service structure, organize the project by responsibility rather than by file type. A practical layout is:

src/
  main.rs
  app.rs
  config.rs
  db.rs
  error.rs
  handlers/
    mod.rs
    items.rs
  models/
    mod.rs
    item.rs
migrations/

This keeps routing, configuration, data access, and domain models separate. For a small service, that separation prevents “main.rs sprawl” and makes the code easier to test. If you later split the service into modules or add background jobs, the structure already supports that growth.

3. Database Design: Schema Planning, Migrations, and PostgreSQL 18-Era Features to Know

Before writing handlers, design the schema around the operations your service actually needs. For a typical CRUD service, a single table might be enough. Suppose we are building an API for items, where each item has a name, optional description, status, and timestamps. The schema should reflect access patterns: if you frequently filter by status or sort by creation time, add indexes accordingly. If some fields are optional and only occasionally queried, avoid over-indexing them early. Good schema design is mostly about minimizing ambiguity and keeping invariants close to the data. (postgresql.org)

A simple PostgreSQL table could look like this:

CREATE TABLE items (
    id UUID PRIMARY KEY,
    name TEXT NOT NULL,
    description TEXT,
    status TEXT NOT NULL DEFAULT 'active',
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_items_status_created_at
    ON items (status, created_at DESC);

This schema is intentionally practical. UUID makes IDs safe to generate outside the database if needed. TIMESTAMPTZ stores timestamps with time zone awareness, which is generally the right choice for services that may run across regions. A composite index on (status, created_at) supports common listing queries. PostgreSQL 18’s skip-scan support is relevant here because multicolumn B-tree indexes can be used in more situations than before, making composite indexes more flexible for some query patterns. PostgreSQL 18 also introduces an asynchronous I/O subsystem intended to improve sequential scans, bitmap heap scans, and vacuum-related work. (postgresql.org)

Schema and migration flow

For migrations, SQLx is a good fit because it keeps schema changes in versioned SQL files. A common pattern is:

sqlx migrate add create_items_table

Then place migration SQL in the generated file. SQLx migrations are helpful because they keep the schema history explicit and reviewable. In a small service, that means you can clone the repo, apply migrations, and have the exact same schema locally as in staging or production. Just as important, it encourages incremental change: one migration per change, one rollback strategy per release, one reviewable diff at a time.

4. Building the API: Request Handling, Routing, Validation, and JSON Responses

Axum is a strong choice for API services because it focuses on ergonomics and modularity. It supports declarative parsing of requests through extractors, and it keeps response generation lightweight. Axum is built to work with Tokio and Hyper, and its design leans on the tower ecosystem for middleware like tracing, timeouts, compression, and CORS. For a service that mostly serves JSON, this means you can assemble a production-quality HTTP layer with relatively little code. (docs.rs)

A simple router might expose health and CRUD endpoints:

use axum::{
    routing::{get, post, put, delete},
    Router,
};

pub fn router() -> Router {
    Router::new()
        .route("/health", get(health))
        .route("/items", post(create_item).get(list_items))
        .route("/items/:id", get(get_item).put(update_item).delete(delete_item))
}

Handlers typically accept typed extractors such as Json<T>, Path<T>, and shared application state. That keeps parsing logic out of the business logic. Validation should happen as early as possible: reject empty names, enforce length limits, and normalize user input before it reaches the database. In a small service, you can perform validation manually or add a validation crate, but the key idea is the same: fail fast with clear error messages. Axum’s extractor-based design is a good fit for this style. (docs.rs)

A JSON request type might look like this:

use serde::Deserialize;

#[derive(Debug, Deserialize)]
pub struct CreateItemRequest {
    pub name: String,
    pub description: Option<String>,
}

And a JSON response type might be:

use serde::Serialize;
use uuid::Uuid;

#[derive(Debug, Serialize)]
pub struct ItemResponse {
    pub id: Uuid,
    pub name: String,
    pub description: Option<String>,
    pub status: String,
}

For APIs, consistency matters more than cleverness. Always return predictable status codes, make error bodies machine-readable, and keep response shapes stable. That discipline pays off as soon as another service or frontend starts relying on your API.

5. Database Access Layer: Async Connections, Pooling, and Query Patterns with Rust

The database layer is where Rust’s async model and PostgreSQL’s transactional semantics meet. SQLx is designed for async SQL access and works with PostgreSQL through the Tokio runtime. In production, you almost never want to open a new database connection per request. Instead, use a connection pool so requests can borrow connections efficiently and return them when finished. This reduces latency and avoids exhausting PostgreSQL with connection churn. (docs.rs)

A shared application state might hold a pool:

use sqlx::PgPool;

#[derive(Clone)]
pub struct AppState {
    pub db: PgPool,
}

You can initialize the pool at startup:

let pool = PgPool::connect(&database_url).await?;

In a small service, this is usually enough to begin with. For more control, you can configure pool size, connection timeout, and idle timeout. The right numbers depend on your service’s concurrency and database capacity, but the basic principle is straightforward: keep enough connections available to avoid queuing, but not so many that PostgreSQL becomes the bottleneck. SQLx’s async APIs are built for this style of pooled access. (docs.rs)

Query patterns should be simple and explicit. Prefer typed queries and clear mappings over dynamic SQL unless you really need runtime query composition. In Rust, a good access layer often looks like a small set of functions:

pub async fn insert_item(pool: &PgPool, name: &str, description: Option<&str>) -> Result<Item, sqlx::Error> {
    sqlx::query_as!(
        Item,
        r#"
        INSERT INTO items (id, name, description)
        VALUES (gen_random_uuid(), $1, $2)
        RETURNING id, name, description, status
        "#,
        name,
        description
    )
    .fetch_one(pool)
    .await
}

In practice, SQLx encourages a “query at the edge” style: keep SQL close to the repository layer, return domain structs, and convert database errors into API errors higher up. That approach is easy to test and easy to review. It also reduces the chance that handler code becomes a second, hidden ORM layer.

6. CRUD Implementation: Create, Read, Update, Delete Endpoints with Error Handling

CRUD endpoints are the heart of most small services, and Rust’s type system helps keep them disciplined. Start by deciding what your API guarantees. For example: creating an item requires a non-empty name; fetching a missing item returns 404; deleting an item is idempotent or returns 404 depending on your contract. Define these behaviors early so the implementation stays consistent. (docs.rs)

A create handler might validate input, write to the database, and return the new record:

pub async fn create_item(
    axum::extract::State(state): axum::extract::State<AppState>,
    axum::Json(payload): axum::Json<CreateItemRequest>,
) -> Result<axum::Json<ItemResponse>, AppError> {
    if payload.name.trim().is_empty() {
        return Err(AppError::validation("name cannot be empty"));
    }

    let item = db::create_item(&state.db, payload).await?;
    Ok(axum::Json(item.into()))
}

Read endpoints often split into list and detail views. A list endpoint can support pagination with limit and offset or cursor-based pagination if you want better large-table performance. A detail endpoint should map a missing row to 404 Not Found rather than a generic server error. Update endpoints should usually support partial updates carefully, because “nullable” in JSON does not always mean “set the database field to null.” This is a common place to be precise with enums or Option<Option<T>> patterns. Delete endpoints should be equally explicit about whether they return 204 No Content or a body. (docs.rs)

Error handling deserves special attention. Create a small application error enum that maps domain and database errors into HTTP responses. For example, a unique constraint violation should likely become 409 Conflict; validation errors should become 400 Bad Request; unexpected failures should become 500 Internal Server Error. This keeps handler code clean and gives clients useful feedback. In Rust, libraries like thiserror and axum’s response traits make this pattern straightforward. The result is a service that is not only correct, but also understandable to consumers.

7. Configuration and Environment Management: Secrets, Connection Strings, and Local Dev Workflow

Configuration should be externalized from the codebase. Database URLs, server bind addresses, log levels, and secrets should live in environment variables or a secret manager, not hardcoded in source. A .env file is fine for local development, but production should rely on the deployment environment or managed secret storage. The goal is to keep the same binary usable across environments while changing only configuration. (doc.rust-lang.org)

A simple config structure might be:

use std::env;

pub struct Config {
    pub database_url: String,
    pub bind_addr: String,
}

impl Config {
    pub fn from_env() -> Self {
        Self {
            database_url: env::var("DATABASE_URL").expect("DATABASE_URL is required"),
            bind_addr: env::var("BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:3000".into()),
        }
    }
}

Local development is usually easiest with a combination of .env, Docker Compose, and SQLx migrations. A typical workflow is: start PostgreSQL, apply migrations, run the service, and hit the API with curl or HTTP client tooling. Keeping the database URL in one environment variable makes it easy to switch between local, test, and production databases without changing code. For example:

DATABASE_URL=postgres://postgres:postgres@localhost:5432/app_db
BIND_ADDR=127.0.0.1:3000
RUST_LOG=info

If you use SQLx with compile-time query checking in your workflow, ensure your local database is reachable when you build or run checks. That gives you stronger guarantees that SQL and Rust types line up. Even if you do not enable compile-time checking immediately, structuring your config around a single connection string keeps the service portable and easy to automate.

8. Testing and Quality: Unit Tests, Integration Tests, Linting, Formatting, and Observability

Testing should cover both logic and integration. Unit tests are great for validation functions, error mapping, and transformation code. Integration tests should exercise the HTTP layer and, ideally, a real PostgreSQL database or a test container. For a small service, that split gives you confidence without turning the test suite into a maintenance burden. Rust’s test framework is built in, and async tests work naturally with Tokio. (docs.rs)

A unit test might verify input validation:

#[test]
fn rejects_empty_names() {
    let req = CreateItemRequest {
        name: "   ".to_string(),
        description: None,
    };

    assert!(req.name.trim().is_empty());
}

An integration test should verify behavior through the API boundary, not by calling private functions. That means sending an HTTP request, checking the response code, and optionally validating the database state afterward. This helps ensure your routing, JSON serialization, validation, and error mapping all work together.

Quality tooling matters too. Run cargo fmt to keep formatting consistent and cargo clippy to catch common mistakes and suspicious patterns. Add tracing so you can observe request flow, latency, and failures. Tokio and Axum fit naturally into a tracing-enabled stack, and tower middleware makes request logging and timing straightforward. For a small service, structured logs plus trace IDs are often enough to diagnose most issues before you need a full observability platform. (docs.rs)

Testing and observability pipeline

9. Deployment Basics: Docker, Environment Variables, and Running Behind a Reverse Proxy

Docker is a practical way to package a Rust web service for deployment. A multi-stage build keeps the runtime image smaller by compiling in one stage and copying the binary into a lean final stage. That approach works especially well for Rust because release binaries are self-contained and predictable. Your container should accept configuration through environment variables and never assume local-only file paths or hardcoded credentials. (doc.rust-lang.org)

A basic Dockerfile might look like this:

FROM rust:1.90 AS builder
WORKDIR /app
COPY . .
RUN cargo build --release

FROM debian:bookworm-slim
WORKDIR /app
COPY --from=builder /app/target/release/rust-postgres-service /usr/local/bin/service
ENV BIND_ADDR=0.0.0.0:3000
EXPOSE 3000
CMD ["service"]

In production, the service usually sits behind a reverse proxy such as Nginx, Caddy, or a cloud load balancer. The reverse proxy handles TLS termination, compression, and sometimes rate limiting or request buffering. Your Rust service can then focus on application logic and JSON responses. Be sure to forward standard headers correctly so the service can log real client IPs and generate accurate absolute URLs if needed.

Environment variables remain the simplest deployment interface. Use them for the database URL, bind address, log level, and secret keys. If you are deploying on a platform that supports secrets injection, prefer that over baked-in .env files. The service should be able to boot cleanly with only its environment and database available. That makes restarts, autoscaling, and blue-green deployments much safer.

10. Performance and Scaling Tips: Indexes, Connection Tuning, and Production Hardening

For a small service, performance usually comes down to avoiding obvious bottlenecks. On the database side, the first lever is indexing. Add indexes only where your queries justify them, and make sure the leading columns match your most common filters and sorts. PostgreSQL 18’s skip-scan support can make multicolumn B-tree indexes more useful in some query patterns, but you still need to design indexes around actual workload behavior. Sequence scans, bitmap heap scans, and vacuum operations may also benefit from PostgreSQL 18’s asynchronous I/O subsystem. (postgresql.org)

Connection tuning is equally important. A pool that is too small causes queuing; a pool that is too large can overwhelm PostgreSQL. Start conservatively and increase only after measuring. Remember that every active database connection consumes server resources, so “more” is not automatically “better.” For a service with modest traffic, a pool sized to the number of CPU cores or slightly above is often a reasonable starting point, but the right answer depends on query latency and concurrency patterns. That’s an inference from how pooled async services behave, not a universal rule. (docs.rs)

Hardening the service involves a collection of small improvements: set request timeouts, cap payload sizes, use structured error responses, validate inputs rigorously, and avoid leaking internal details in error messages. Add metrics if you can, especially request counts, latency histograms, and database error rates. In production, the most common failure modes are usually not exotic Rust bugs; they are slow queries, bad credentials, misconfigured environment variables, and overloaded connection pools. The better your service reports those conditions, the faster you can fix them.

Conclusion

Rust and PostgreSQL are a strong combination for small web services because they offer correctness, performance, and operational clarity without forcing unnecessary complexity. Rust gives you compile-time safety, async concurrency with Tokio, and a well-structured ecosystem around Axum and SQLx. PostgreSQL gives you a mature relational core, robust migrations, and modern features in version 18 that continue to improve query and scan behavior. (docs.rs)

If you follow the workflow in this guide, you end up with more than a toy example. You get a service scaffold that can validate requests, persist data safely, surface meaningful errors, run tests, and deploy cleanly in containers. The main takeaway is that a small service does not need to be simplistic. With a disciplined stack and clear boundaries, it can be both compact and production-ready.

References