Introduction
As the Rust language becomes increasingly popular in backend development, Axum, a high-performance and type-safe web framework, is gaining favor among developers. This article will guide you on how to integrate Docker and PostgreSQL database into an Axum project, and provide an in-depth understanding of how database connection pools work, which are fundamental elements for building high-performance APIs.
We will demonstrate step-by-step through practical examples how to set up an API backend similar to Medium, from database selection to Docker environment setup, and then to the implementation of Axum’s connection to the database. Whether you are a Rust beginner or an experienced developer, this article will help you master the key technologies for building modern web services.
Database Selection
Before starting the project, we need to make an important decision: which database to choose? This is not just a matter of personal preference, but a reasonable choice based on project requirements.
Understanding Database Types
There are mainly two types of databases commonly used for building APIs:
- **Relational Databases (SQL)**:
- MySQL, PostgreSQL, SQLite, etc.
- Store data in tables and establish relationships using foreign keys
-- Example: Relationship between Users and Articles
Users Table:
| id | username | email |
|----|----------|----------------|
| 1 | john | [email protected] |
Articles Table:
| id | title | author_id | content |
|----|-----------|-----------|---------|
| 1 | "Hello" | 1 | "..." |
- NoSQL Databases:
- MongoDB, Redis, DynamoDB, etc.
- Store data in various formats (documents, key-value pairs, graphs, etc.)
- No predefined schema required, can store different shapes of data
// MongoDB Document Example
{
"_id": "507f1f77bcf86cd799439011",
"username": "john",
"email": "[email protected]",
"articles": [
{
"title": "Hello",
"content": "...",
"tags": ["intro", "welcome"]
}
]
}
Analyzing Our Medium API Requirements
Our API needs to support the following functionalities:
-
Data Relationships:
- Users can follow other users
- Users can write articles
- Articles have tags (many-to-many relationship)
- Users can favorite articles
- Users can comment on articles
-
Data Consistency Requirements:
- When a user is deleted, their articles need to be handled properly
- Favorite counts must be accurate
- Follow relationships must be consistent
-
Query Patterns:
- Complex filtering: Show articles with specific tags from authors I follow
- Aggregation: Count of favorites for each article
- Join: Get articles with author information and tags
SQL vs NoSQL Comparison
Considering that our API has highly interconnected data and complex relationships, SQL databases are more suitable for our needs due to their ability to manage relationships through foreign keys and efficiently handle complex queries. If we were to use NoSQL, we would need to handle data consistency in the application code and perform multiple queries to complete complex operations.
Choosing PostgreSQL
Among SQL databases, we choose PostgreSQL because it:
- Handles complex queries better
- Offers richer data types (UUID, arrays, JSONB)
- Integrates well with the Rust ecosystem (SQLx provides compile-time query checks)
- Can efficiently handle complex queries like:
SELECT a.title, u.username, array_agg(t.name) as tags
FROM articles a
JOIN users u ON a.author_id = u.id
JOIN article_tags at ON a.id = at.article_id
JOIN tags t ON at.tag_id = t.id
WHERE u.id IN (SELECT followed_id FROM follows WHERE follower_id = $1)
GROUP BY a.id, u.username;
Docker Basics and Environment Setup
Before diving into the code, we need to understand Docker and its role in the project.
Problems Solved by Docker
Imagine you need to install PostgreSQL on different machines:
- Download and install the installer
- Configure it correctly
- Set up users and permissions
- Remember these steps to repeat on other machines
In a team, everyone might be using different operating systems and different versions of PostgreSQL, leading to the classic “it works on my machine” problem.
How Docker Works
Docker solves the above problems by creating isolated environments called containers. A container includes:
- The application you want to run (like PostgreSQL)
- All libraries and dependencies required by the application
- A mini operating system environment
This container can run in the same way on any computer that has Docker installed.
Core Concepts of Docker
1. Images
Docker images are like snapshots of software installation packages, for example, a PostgreSQL image includes:
- A specific version of the PostgreSQL database software
- All libraries required by PostgreSQL
- Operating system files required by PostgreSQL
- Default configurations
Download the PostgreSQL image:
docker pull postgres:15
2. Containers
Containers are running instances of images. If we compare an image to a software package on disk (like an .exe file), a container is like a running program.
3. Isolation
When we run PostgreSQL in a container:
- It cannot see our personal files
- It does not interfere with other software on the computer
- It thinks it is running on a dedicated machine (isolation)
- If it crashes or gets corrupted, it does not affect our computer or other containers
4. Port Mapping
Since containers are isolated, we need to create a network bridge to access applications running inside the container. Port mapping creates a bridge between our computer and the container.
Starting the PostgreSQL Container
Run the following command to start the PostgreSQL container:
docker run --name realworld-db \
-e POSTGRES_PASSWORD=realworld123 \
-e POSTGRES_USER=realworld \
-e POSTGRES_DB=realworld_dev \
-p 5432:5432 \
-d postgres:15
Parameter explanations:
<span>docker run</span>creates and starts the container<span>--name realworld-db</span>gives the container a memorable name<span>-e</span>sets environment variables (password, user, and database name)<span>-p 5432:5432</span>port mapping, mapping the container’s port 5432 to the host’s port 5432<span>-d</span>runs in the background<span>postgres:15</span>the image used
Testing PostgreSQL Connection
Use the following command to connect to our PostgreSQL database:
docker exec -it realworld-db psql -U realworld -d realworld_dev
Command explanations:
<span>docker exec</span>executes a command inside the running container<span>-it</span>interactive mode, allocates a pseudo terminal<span>realworld-db</span>container name<span>psql -U realworld -d realworld_dev</span>connects to the database “realworld_dev” using user “realworld”
Container Lifecycle Management
- Stop the container:
<span>docker stop realworld-db</span> - Check status:
<span>docker ps -a</span> - Start the container:
<span>docker start realworld-db</span>
Note: Stopping the container retains the data inside the container. Only deleting the container (
<span>docker rm</span>) will delete everything.
Database Connections and Connection Pools
Before connecting Axum to PostgreSQL, we need to understand the concepts of database connections and connection pools.
Problems with Individual Connections
Imagine a scenario where our API receives 100 requests per second. If each request creates its own database connection:
Request 1: Connect → Query → Disconnect
Request 2: Connect → Query → Disconnect
Request 3: Connect → Query → Disconnect
...and so on
This approach has the following issues:
- High Connection Overhead: Creating a connection requires TCP socket creation, handshake, authentication, session initialization, etc., adding latency.
- High Resource Consumption: PostgreSQL’s default maximum connection count is 100, with each connection consuming 2-8MB of memory and CPU resources.
A typical web server request lifecycle:
1. User clicks "Get Article" button
2. HTTP request reaches the server [0ms]
3. Application creates a database connection [20ms] ← Connection overhead
4. Application authenticates with the database [10ms] ← Authentication overhead
5. Application sends SQL query [1ms] ← Actual work
6. Database processes the query [5ms] ← Actual work
7. Database returns results [1ms] ← Actual work
8. Application closes the database connection [5ms] ← Connection overhead
9. Application sends HTTP response [1ms]
As we can see, completing the request takes a total of 43ms, but the actual database operations only take 7ms. We spend a lot of time on database connection, authentication, and closing connections.
Connection Pooling
A connection pool is a mechanism that caches database connections, keeping them open and available. The application borrows connections from this pre-established pool instead of creating new connections for each request.
How it works:
- When the server starts, it creates 5-10 connections to PostgreSQL
- Each connection is validated and kept open
- All connections are placed in a “pool” (queue) waiting to be used
Request flow:
- A request arrives: “I need to query the user table”
- The application asks the pool: “Give me an available connection”
- The pool responds: “Here is connection #3, you can use it”
- The application uses connection #3 to run the SQL query
- The application gets the results
- The application tells the pool: “I am done using connection #3”
- The pool marks connection #3 as available again
Key Point: Connection #3 is never closed. It maintains its connection to PostgreSQL and is reused by the next request.
When the connection pool is exhausted, we can:
- Increase the pool size (but it will use more database resources)
- Place new requests in a waiting queue
- Set request timeouts (e.g., 30 seconds)
Implementing Axum Connection to PostgreSQL
Adding Database Dependencies
Add the following dependencies in Cargo.toml:
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "migrate"] }
uuid = { version = "1.0", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
dotenvy = "0.15"
Dependency explanations:
- sqlx: PostgreSQL database driver and migration support
- uuid: Generates unique identifiers for users, articles, and comments
- chrono: Handles created_at and updated_at timestamps
- dotenvy: Loads environment variables from .env file
Environment Configuration
Create a .env file to store the database connection string:
# .env
DATABASE_URL=postgresql://realworld:realworld123@localhost:5432/realworld_dev
Create a .env.example file:
# .env.example
DATABASE_URL=postgresql://username:password@localhost:5432/database_name
Update the .gitignore file:
# .gitignore
/target
.env
Creating Application State
Create src/state.rs file:
use sqlx::PgPool;
#[derive(Clone)]
pub struct AppState {
pub db: PgPool, // Database connection pool
}
impl AppState {
pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> {
// Create database connection pool
let db = PgPool::connect(database_url).await?;
Ok(Self { db })
}
}
Code explanation:
<span>AppState</span>struct holds shared application data accessible to all handlers<span>PgPool</span>is our database connection pool shared across all requests<span>#[derive(Clone)]</span>allows us to efficiently clone this struct<span>new</span>method creates our shared state
Updating main.rs
use axum::{routing::get, Router};
use std::env;
mod handlers;
mod state;
use handlers::health::health_check;
use state::AppState;
#[tokio::main]
async fn main() {
// Load environment variables from .env file
dotenvy::dotenv().ok();
// Read database URL
let database_url = env::var("DATABASE_URL")
.expect("DATABASE_URL must be set in .env file or environment");
// Create shared state
let app_state = AppState::new(&database_url)
.await
.expect("Failed to connect to database");
println!("Connected to database successfully!");
// Set up routes
let app = Router::new()
.route("/health", get(health_check))
.with_state(app_state);
// Start server
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
println!("Server running on http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}
Code explanation:
- Load environment variables
- Read database URL
- Create shared state (connection pool)
- Set up routes and share state
- Start server
Updating Health Check Handler
Create or update src/handlers/health.rs:
use axum::{extract::State, Json};
use serde_json::{json, Value};
use crate::state::AppState;
pub async fn health_check(State(state): State<AppState>) -> Json<Value> {
// Test database connection
match sqlx::query("SELECT 1").execute(&state.db).await {
Ok(_) => Json(json!({
"status": "ok",
"database": "connected"
})),
Err(e) => {
eprintln!("Database error: {}", e);
Json(json!({
"status": "error",
"database": "disconnected",
"error": e.to_string()
}))
}
}
}
Code explanation:
<span>State(state): State<AppState></span>– Axum’s state extraction mechanism<span>sqlx::query("SELECT 1")</span>creates a simple SQL query<span>SELECT 1</span>is a standard database “ping” test<span>.execute(&state.db)</span>runs the query using our shared connection pool- Returns a corresponding JSON response based on query success or failure
Testing Database Connection
- Ensure the PostgreSQL container is running:
docker ps
- Start the Rust application:
cargo run
You should see:
Connected to database successfully!
Server running on http://localhost:3000
- Test the health endpoint:
curl http://localhost:3000/health
Expected response:
{
"status": "ok",
"database": "connected"
}
Understanding the Complete Request Flow
When someone accesses the /health endpoint, the following process occurs:
- The curl command sends a GET request to port 3000
- The TCP listener accepts the connection
- Axum matches /health + GET method to the health_check handler
- Axum finds that the handler requires State and provides it
- &state.db borrows an available connection from the pool
- SELECT 1 is sent to PostgreSQL
- PostgreSQL returns results
- The connection is automatically returned to the pool
- The handler creates a JSON response based on success/failure
- Axum sends the JSON back to the curl command
Conclusion
In this article, we delved into how to integrate the Axum backend with a PostgreSQL database and used Docker to simplify the development environment setup. We learned:
- Database Selection: Analyzed the pros and cons of SQL and NoSQL, ultimately choosing PostgreSQL as our database solution.
- Docker Basics: Understood the core concepts of Docker (images, containers, isolation, and port mapping) and successfully ran a PostgreSQL container.
- Database Connection Pooling: Gained an in-depth understanding of how connection pools work and how they can significantly improve application performance.
- Axum Integration: Implemented the connection between Axum and PostgreSQL, created a shared state pattern, and wrote a health check endpoint to validate the connection.
These foundational knowledge points lay a solid groundwork for building high-performance, reliable web APIs. In the upcoming articles, we will create database tables, learn about migrations, SQLx, schema design, and set up appropriate error handling to start building actual API functionalities.
With this combination of technologies, we can build efficient and reliable Rust backend services, fully leveraging Rust’s performance and safety advantages while enjoying the conveniences offered by modern development toolchains.
References
- Axum Backend Series: Docker, Database and Connection Pooling: https://blog.0xshadow.dev/posts/backend-engineering-with-axum/axum-database-setup-using-docker/
Book Recommendations
The second edition of “The Rust Programming Language” is an authoritative learning resource written by the Rust core development team and translated by members of the Chinese Rust community. It is suitable for all software developers who wish to evaluate, get started, improve, and study the Rust language, and is regarded as essential reading for Rust development work.
This book introduces the fundamental concepts of the Rust language to unique practical tools in a gradual manner, covering advanced concepts such as ownership, traits, lifetimes, safety guarantees, as well as practical tools like pattern matching, error handling, package management, functional features, and concurrency mechanisms. The book includes three complete project development case studies, guiding readers to develop Rust practical projects from scratch.
Notably, this book has been updated to the Rust 2021 version, meeting the systematic learning needs of beginners and serving as a reference guide for experienced developers, making it the best entry point for building solid Rust skills.
Recommended Reading
-
Rust: The Performance King Sweeping C/C++/Go?
-
A Look at C++ from the Perspective of Rust Developers: Pros and Cons Revealed
-
Rust vs Zig: The Emerging System Programming Language Battle
-
Essential Design Patterns for Asynchronous Programming in Rust: Enhance Your Code Performance and Maintainability