Let's create a CRUD Rest API in Rust, using the following:
- Axum (Rust web framework)
- sqlx (ORM)
- Postgres (database)
- Docker (containerization)
- Docker Compose (to run the application and the database in containers)
If you prefer a video version:
All the code is available in the GitHub repository (link in the video description):
π Intro
Here is a schema of the architecture of the application we are going to create:
We will create 5 endpoints for basic CRUD operations:
- Create
- Read all
- Read one
- Update
- Delete
We will also create a simple endpoint to test if the server is running.
Here are the steps we are going through:
- Create a compose.yml file and ru the Postgres instance
- Create an Axum application using sqlx as an ORM
- Dockerize the Axum application
- Run the Axum application using docker compose
- Test the application
We will go with a step-by-step guide, so you can follow along.
π Prerequisites
Before starting, make sure you have the following installed on your machine:
- Docker
- Rust and Cargo
π Project initialization
Let's iitialize the project:
cargo new axumlive
This will create a new folder called axumlive with the following structure:
axumlive
β Cargo.toml
ββββsrc
β main.rs
Run the Postgres container
Let's run the Postgres container first, so we have the database ready when we will run the Axum application.
To do that, create a file called compose.yml in the root of the project (axumlive folder) with the following content:
db:
image: postgres:15
container_name: db
ports:
- "5432:5432"
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: simple_api
volumes:
- pg_data:/var/lib/postgresql/data
volumes:
pg_data:
Now, to run the Postgres container, type:
docker compose up -d db
This will run the Postgres container in detached mode (in the background).
If you see something like this, it means that the container is running:
Now, to check if the container is running, type:
docker ps -a
If everything is ok, you should see something like this:
Now before we write our Axum application, let's step inside the Postgres container:
docker exec -it db psql -U user -d simple_api
This will open the Postgres shell.
You should see something like this:
Now type:
\dt
And you should see "didn't find any relations" because the database is empty.
This is normal but let's keep this terminal open somewhere. We will use it later to check if the tables are created correctly.
π¦ Add dependencies and the Sql migrations
Let's add the dependencies we need to the Cargo.toml file.
...
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "macros"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Since we want a convenient way to create the tables in the database, we will use sqlx migrations. This allows us to create the table the first time we run the application. This is also convenient in case someone else wants to run the application, they just have to run the migrations and the tables will be created automatically.
Now, let's create a folder called migrations in the root of the project (axumlive folder) and inside it create a file called 0001_users_table.sql with the following content:
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);
We are now ready to write the Axum application.
π¦ Add The Dependncies
Open the Cargo.toml file and add the following dependencies:
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "macros"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
π¦ Create the Axum backend
Let's open the main.rs file and populate it with the following code:
use axum::{ extract::{ Path, State }, http::StatusCode, routing::{ get, post }, Json, Router };
use serde::{ Deserialize, Serialize };
use sqlx::{ postgres::PgPoolOptions, FromRow, PgPool };
use std::env;
#[derive(Deserialize)]
struct UserPayload {
name: String,
email: String,
}
#[derive(Serialize, FromRow)]
struct User {
id: i32,
name: String,
email: String,
}
#[tokio::main]
async fn main() {
let db_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = PgPoolOptions::new().connect(&db_url).await.expect("Failed to connect to DB");
sqlx::migrate!().run(&pool).await.expect("Migrations failed");
let app = Router::new()
.route("/", get(root))
.route("/users", post(create_user).get(list_users))
.route("/users/{id}", get(get_user).put(update_user).delete(delete_user))
.with_state(pool);
let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap();
println!("π Server running on port 8000");
axum::serve(listener, app).await.unwrap();
}
//Endpoint Handlers
//test endpoint
async fn root() -> &'static str {
"Welcome to the User Management API!"
}
//GET ALL
async fn list_users(State(pool): State<PgPool>) -> Result<Json<Vec<User>>, StatusCode> {
sqlx::query_as::<_, User>("SELECT * FROM users")
.fetch_all(&pool).await
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
//CREATE USER
async fn create_user(
State(pool): State<PgPool>,
Json(payload): Json<UserPayload>
) -> Result<(StatusCode, Json<User>), StatusCode> {
sqlx::query_as::<_, User>("INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *")
.bind(payload.name)
.bind(payload.email)
.fetch_one(&pool).await
.map(|u| (StatusCode::CREATED, Json(u)))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
//GET USER BY ID
async fn get_user(
State(pool): State<PgPool>,
Path(id): Path<i32>
) -> Result<Json<User>, StatusCode> {
sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(id)
.fetch_one(&pool).await
.map(Json)
.map_err(|_| StatusCode::NOT_FOUND)
}
//UPDATE USER
async fn update_user(
State(pool): State<PgPool>,
Path(id): Path<i32>,
Json(payload): Json<UserPayload>
) -> Result<Json<User>, StatusCode> {
sqlx::query_as::<_, User>("UPDATE users SET name = $1, email = $2 WHERE id = $3 RETURNING *")
.bind(payload.name)
.bind(payload.email)
.bind(id)
.fetch_one(&pool).await
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
//DELETE USER
async fn delete_user(
State(pool): State<PgPool>,
Path(id): Path<i32>
) -> Result<StatusCode, StatusCode> {
let result = sqlx
::query("DELETE FROM users WHERE id = $1")
.bind(id)
.execute(&pool).await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if result.rows_affected() == 0 {
Err(StatusCode::NOT_FOUND)
} else {
Ok(StatusCode::NO_CONTENT)
}
}
Explanation:
- We define the data structures for the user payload and the user model using Serde for serialization and deserialization.
- We set up the database connection using sqlx and run the migrations to create the users table
- We define the Axum routes for the CRUD operations and associate them with their respective handler functions
- Each handler function interacts with the database using sqlx to perform the required operations and returns appropriate HTTP responses.
- Finally, we start the Axum server on port 8000. ___ ## π³ Dockerize the Axum application
It's time to Dockerize the Rust application. To do this, first let's create a .dockerignore file in the root of the project (axumlive folder) with the following content:
target
.git
This will prevent Docker from copying the target folder and the .git folder into the Docker image, which are not needed.
In the root folder of the project (axumlive), create a file called Dockerfile
This one will be a multi-stage Dockerfile, to keep the final image as small as possible.
Let's populate the Dockerfile
Dockerfile:
#stage 1
FROM rust:1.91 as builder
WORKDIR /app
COPY . .
RUN cargo build --release
#stage 2
FROM debian:bookworm-slim
WORKDIR /app
COPY --from=builder /app/target/release/axumlive .
EXPOSE 8000
CMD ["./axumlive"]
π³π³Docker compose
Since now we defined the Axum application and we dockerized it, it's time to add this service to the compose.yml file, so we can run both the Axum application and the Postgres database with a single command.
Populate the docker-compose.yml file:
services:
app:
container_name: simple_axum
build: .
ports:
- "8000:8000"
environment:
DATABASE_URL: postgres://user:password@db:5432/simple_api
db:
image: postgres:15
container_name: db
ports:
- "5432:5432"
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: simple_api
volumes:
- pg_data:/var/lib/postgresql/data
volumes:
pg_data:
We defined a new service called "app" which is the Axum application. We set the build context to the current directory (.) and we map the port 8000 of the container to the port 8000 of the host machine.
π Build and Run the Axum appliation
Now let's build so we can run the Axum application.
Let's type
docker compose build
And you should see something like this:
This should BUILD the app image, with the name defined in the "image" value, in this case, ""simple_axum".
You can also see all the steps docker did to build the image, layer by layer. You might recognize some of them, because we defined them in the Dockerfile.
Now let's run the app service:
docker compose up -d app
This will run the app service in detached mode (in the background).
Let's check if both the containrs are running:
docker ps -a
Important step: is you go back to the psql terminal where we stepped into the Postgres container, and type:
\dt
you should now see the users table created by the migration:
Of course, this talbe is currenlty empty, but at least we know that the migration ran successfully when the Axum application started.
π Test the application
Now let's test our application.
The first step is to visit http://localhost:8000/ in your browser:
You can tet it in different ways. In this specific case, I will use a VS Code extension called REST Client, but you can use Postman, curl or any other tool you prefer.
You can test the application in the way you prefer, but for convenience I will add a file you can copy paste in your application at the root level, called request.hhtp, with all the requests we need to test the applicationL
// requests.http
@baseUrl = http://localhost:8000
### 1. Test Root Endpoint (Health Check)
GET {{baseUrl}}/
### 2. Get All Users (Should be empty initially)
GET {{baseUrl}}/users
### 3. Create User 1
POST {{baseUrl}}/users
Content-Type: application/json
{
"name": "Alice Smith",
"email": "alice@example.com"
}
### 4. Create User 2
POST {{baseUrl}}/users
Content-Type: application/json
{
"name": "Bob Jones",
"email": "bob@example.com"
}
### 5. Create User 3
POST {{baseUrl}}/users
Content-Type: application/json
{
"name": "Charlie Day",
"email": "charlie@example.com"
}
### 6. Get All Users (Should see 3 users now)
GET {{baseUrl}}/users
### 7. Get Single User (Assuming ID 1 exists)
GET {{baseUrl}}/users/1
### 8. Get User that does not exist (Test 404)
GET {{baseUrl}}/users/9999
### 9. Update User 2
# Note: Ensure your backend handles PUT or PATCH for updates
PUT {{baseUrl}}/users/2
Content-Type: application/json
{
"name": "Bob James",
"email": "bobjames@example.com"
}
### 10. Get All Users (Verify Update)
GET {{baseUrl}}/users
### 11. Delete User 2
DELETE {{baseUrl}}/users/3
### 12. Get All Users (Final check - Bob should be gone)
GET {{baseUrl}}/users
You can test the example Endoiunt by runnig the first request in the file
, which is a GET request to localhost:8000/
π Create a user
Now let's create a user, making a POST request to localhost:8000/users with the body below as a request body:
Let's create another one:
One more:
π Get all users
Now, let's make a GET request to localhost:8000/users to get all the users:
We just created 3 users.
From the browser, we can see:
If we go back to the psql terminal where we stepped into the Postgres container, and type:
SELECT * FROM users;
we should see the 3 users we just created:
π Get a specific user
If you want to get a specific user, you can make a GET request to localhost:4000/users/<user_id>.
For example, to get the user with id 1, you can make a GET request to localhost:8000/users/1
If we try to get a user that does not exist, for example the user with id 9999, we should get a 404 error:
π Update a user
If you want to update a user, you can make a PUT request to localhost:8000/users/<user_id>.
For example, to update the user with id 2, you can make a PUT request to localhost:8000/users/2 with the body below as a request body:
π Delete a user
To delete a user, you can make a DELETE request to localhost:4000/users/<user_id>.
For Example, to delete the user with id 3, you can make a DELETE request to localhost:8000/users/3
To check if the user has been deleted, you can make a GET request to localhost:8000/users or check directly in the browser:
As you can see the user with id 3 is not there anymore.
π Conclusion
We made it! We have built a CRUD rest API in Rust using Axum, sqlx, and Postgres, and we dockerized the application.
This is just an example, but you can use this as a starting point to build your own application.
If you prefer a video version:
All the code is available in the GitHub repository (link in the video description): https://youtu.be/cJyl9e2oqHY
That's all.
If you have any question, drop a comment below.





















Top comments (0)