In this blog, I write about Web Development, Blockchain, and DevOps. I am a Docker Captain, Public Speaker, and Developer Advocate at daily.dev. Check my YouTube channel.
By the end of this article, you will understand and create a simple yet complete full stack app using the following:
Next.js 14 (TypeScript)
Tailwind CSS
Rust (no framework, Serde for serialization)
PostgreSQL
Docker
Docker Compose
There are MANY technologies, but we'll keep the example as basic as possible to make it understandable.
We will proceed with a bottom-up approach, starting with the database and ending with the frontend.
If you prefer a video version: https://youtu.be/77RjzJtC_g4
All the code is available for free on GitHub (link in video description).
Architecture
Before we start, here is a simple schema explaining the app's architecture.
Build a FULL STACK Web app with Rust API, Next.js 14, Serde, Postgres, Docker, docker Compose
The frontend is a Next.js app with TypeScript and Tailwind CSS.
The backend is written in plain Rust, without any framework, but we'll use Serde for serialization and Deserialization.
The database is PostgreSQL. We will use Docker to run the database, the backend, and also the frontend (you can also use Vercel). We will use Docker Compose to run the frontend, the backend, and the database together.
Prerequisites
Basic knowledge of what is a frontend, a backend, an API, and a database
Docker installed on your machine
Rust installed on your machine (we will use cargo to build the backend)
(optional) Postman or any other tool to make HTTP requests
1. Preparation
Create any folder you want, and then open it with your favorite code editor.
mkdir <YOUR_FOLDER>
cd <YOUR_FOLDER>
code .
Initialize a git repository.
git init
touch .gitignore
Populate the .gitignore file with the following content:
*node_modules
Create a file called compose.yaml in the project's root.
touch compose.yaml
Your projects should look like this:
We are ready to create the fullstack app and build it from the bottom up, starting with the database.
After each step, we will test the app's current state to ensure that everything is working as expected.
2. Database
We will use Postgres but not install it on our machine. Instead, we will use Docker to run it in a container. This way, we can easily start and stop the database without installing it on our machine.
Open the file compose.yaml and add the following content:
This will pull the Postgres image from Docker Hub and start the container. The -d flag means that the container will run in detached mode so we can continue to use the terminal.
Check if the container is running:
docker ps -a
Step into the db container
docker exec -it db psql -U postgres
Now that you are in the Postgres container, you can type:
\l
\dt
And you should see no relations.
You can now exit the container with the exit command.
3. Backend
The first step is done. Now, we will create the backend. We will use Rust.
Let's create the backend app using cargo, the Rust package manager.
cargo new backend
Open the file called Cargo.toml and add the following content:
postgres is the Postgres driver for Rust. serde is a library to serialize and deserialize. serde_json is a library specific for JSON. serde_derive is a library to derive the Serialize and Deserialize traits (macro)
Your Cargo.toml file should look like this:
[package]
name = "rust-crud-api"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
postgres = "0.19"
serde = "1.0"
serde_json = "1.0"
serde_derive = "1.0"
Please notice that the Package name could differ based on the name you gave to your project.
Your project should now look like this:
We are now ready to code the application.
๐ฉโ๐ป Code the Rust backend application
We will go step by step:
Import the dependencies.
Create the model (a user with Id, name, and email) and add constants.
Main function: database connection and TCP server.
For this project, we will code everything in a single file of ~200 lines of code.
This is not a best practice, but it will help us focus on the Rust code, not the project structure.
All the code is available on GitHub (link in the video description).
โฌ๏ธ Import the dependencies
Open the main.rsfile, in the src folder, delete all the code, and add the following imports:
use postgres::{ Client, NoTls };
use postgres::Error as PostgresError;
use std::net::{ TcpListener, TcpStream };
use std::io::{ Read, Write };
use std::env;
#[macro_use]externcrate serde_derive;
Client is used to connect to the database. NoTls is used to connect to the database without TLS. PostgresError is the error type returned by the Postgres driver. TcpListener and TcpStream to create a TCP server. Read and Write are used to read and write from a TCP stream. env is used to read the environment variables.
the #[macro_use] attribute is used to import the serde_derive macro.
We will use it to derive our model's Serialize and Deserialize traits.
๐ฅป Create the model
Just below the imports, add the following code:
//Model: User struct with id, name, email#[derive(Serialize, Deserialize)]structUser {
id: Option<i32>,
name: String,
email: String,
}
We will use this model to represent a user in our application.
id is an integer and is optional. The reason is that we don't provide the id when we create or update a new user. The database will generate it for us. But we still want to return the user with an id when we get them.
name is a string, and it is mandatory. We will use it to store the name of the user.
email is a string, and it is mandatory. We will use it to store the user's email (there is no check if it's a valid email).
๐ชจ Constants
Just below the model, add the following constants:
DATABASE_URL is the environment variable that we will use to connect to the database. We will set it later.
OK_RESPONSE is the response that we will send when everything is ok. It contains the status code, the content type, and the CORS headers.
NOT_FOUND is the response that we will send when the requested resource is not found.
INTERNAL_ERROR is the response that we will send when there is an internal error.
๐ Main function
Just below the constants, add the following code:
//main functionfnmain() {
//Set DatabaseifletErr(_) = set_database() {
println!("Error setting database");
return;
}
//start server and print portlet listener = TcpListener::bind(format!("0.0.0.0:8080")).unwrap();
println!("Server listening on port 8080");
for stream in listener.incoming() {
match stream {
Ok(stream) => {
handle_client(stream);
}
Err(e) => {
println!("Unable to connect: {}", e);
}
}
}
}
set_database is a function that we will create later. It will be used to connect to the database.
TcpListener::bind is used to create a TCP server listening on port 8080.
listener.incoming() is used to get the incoming connections.
โ๏ธ Utility functions
Now, out of the main function, add the three following utility functions. I will keep them at the bottom of the file, but you can put them wherever you want.
//db setupfnset_database() -> Result<(), PostgresError> {
letmut client = Client::connect(DB_URL, NoTls)?;
client.batch_execute(
"
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
email VARCHAR NOT NULL
)
"
)?;
Ok(())
}
//Get id from request URLfnget_id(request: &str) -> &str {
request.split("/").nth(4).unwrap_or_default().split_whitespace().next().unwrap_or_default()
}
//deserialize user from request body without idfnget_user_request_body(request: &str) -> Result<User, serde_json::Error> {
serde_json::from_str(request.split("\r\n\r\n").last().unwrap_or_default())
}
set_database connects to the database and creates the users table if it doesn't exist.
get_id is used to get the id from the request URL.
get_user_request_body is used to deserialize the user from the request body (without the id) for the Create and Update endpoints.
๐ฆ Handle client
Between the main function and the utility functions, add the following code (no worries, there will be the final code at the end of the article):
//handle requestsfnhandle_client(mut stream: TcpStream) {
letmut buffer = [0; 1024];
letmut request = String::new();
match stream.read(&mut buffer) {
Ok(size) => {
request.push_str(String::from_utf8_lossy(&buffer[..size]).as_ref());
let (status_line, content) = match &*request {
r if r.starts_with("OPTIONS") => (OK_RESPONSE.to_string(), "".to_string()),
r if r.starts_with("POST /api/rust/users") => handle_post_request(r),
r if r.starts_with("GET /api/rust/users/") => handle_get_request(r),
r if r.starts_with("GET /api/rust/users") => handle_get_all_request(r),
r if r.starts_with("PUT /api/rust/users/") => handle_put_request(r),
r if r.starts_with("DELETE /api/rust/users/") => handle_delete_request(r),
_ => (NOT_FOUND.to_string(), "404 not found".to_string()),
};
stream.write_all(format!("{}{}", status_line, content).as_bytes()).unwrap();
}
Err(e) => eprintln!("Unable to read stream: {}", e),
}
}
We create a buffer and then a string for the incoming requests.
Using the match statement in Rust, we can check the request and call the right function to handle it.
If we don't have a match, we send back a 404 error.
Last, we set the stream to write the response back to the client and handle any error.
๐๏ธ Controllers
Now, let's create the functions that will handle the requests.
They are five functions, one for each endpoint:
handle_post_request for the Create endpoint
handle_get_request for the Read endpoint
handle_get_all_request for the Read All endpoint
handle_put_request for the Update endpoint
handle_delete_request for the Delete endpoint
Add the code below the handle_client function:
//handle post requestfnhandle_post_request(request: &str) -> (String, String) {
match (get_user_request_body(request), Client::connect(DB_URL, NoTls)) {
(Ok(user), Ok(mut client)) => {
// Insert the user and retrieve the IDlet row = client
.query_one(
"INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id",
&[&user.name, &user.email]
)
.unwrap();
let user_id: i32 = row.get(0);
// Fetch the created user datamatch client.query_one("SELECT id, name, email FROM users WHERE id = $1", &[&user_id]) {
Ok(row) => {
let user = User {
id: Some(row.get(0)),
name: row.get(1),
email: row.get(2),
};
(OK_RESPONSE.to_string(), serde_json::to_string(&user).unwrap())
}
Err(_) =>
(INTERNAL_ERROR.to_string(), "Failed to retrieve created user".to_string()),
}
}
_ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
}
}
//handle get requestfnhandle_get_request(request: &str) -> (String, String) {
match (get_id(&request).parse::<i32>(), Client::connect(DB_URL, NoTls)) {
(Ok(id), Ok(mut client)) =>
match client.query_one("SELECT * FROM users WHERE id = $1", &[&id]) {
Ok(row) => {
let user = User {
id: row.get(0),
name: row.get(1),
email: row.get(2),
};
(OK_RESPONSE.to_string(), serde_json::to_string(&user).unwrap())
}
_ => (NOT_FOUND.to_string(), "User not found".to_string()),
}
_ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
}
}
//handle get all requestfnhandle_get_all_request(_request: &str) -> (String, String) {
match Client::connect(DB_URL, NoTls) {
Ok(mut client) => {
letmut users = Vec::new();
for row in client.query("SELECT id, name, email FROM users", &[]).unwrap() {
users.push(User {
id: row.get(0),
name: row.get(1),
email: row.get(2),
});
}
(OK_RESPONSE.to_string(), serde_json::to_string(&users).unwrap())
}
_ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
}
}
//handle put requestfnhandle_put_request(request: &str) -> (String, String) {
match
(
get_id(&request).parse::<i32>(),
get_user_request_body(&request),
Client::connect(DB_URL, NoTls),
)
{
(Ok(id), Ok(user), Ok(mut client)) => {
client
.execute(
"UPDATE users SET name = $1, email = $2 WHERE id = $3",
&[&user.name, &user.email, &id]
)
.unwrap();
(OK_RESPONSE.to_string(), "User updated".to_string())
}
_ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
}
}
//handle delete requestfnhandle_delete_request(request: &str) -> (String, String) {
match (get_id(&request).parse::<i32>(), Client::connect(DB_URL, NoTls)) {
(Ok(id), Ok(mut client)) => {
let rows_affected = client.execute("DELETE FROM users WHERE id = $1", &[&id]).unwrap();
//if rows affected is 0, user not foundif rows_affected == 0 {
return (NOT_FOUND.to_string(), "User not found".to_string());
}
(OK_RESPONSE.to_string(), "User deleted".to_string())
}
_ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
}
}
Some use the get_id function to get the id from the request URL.
The get_user_request_body function is used to get the user from the request body in JSON format and deserialize it into a User struct.
There is some error handling in case the request is invalid, or the database connection fails.
use postgres::{ Client, NoTls };
use postgres::Error as PostgresError;
use std::net::{ TcpListener, TcpStream };
use std::io::{ Read, Write };
use std::env;
#[macro_use]externcrate serde_derive;
//Model: User struct with id, name, email#[derive(Serialize, Deserialize)]structUser {
id: Option<i32>,
name: String,
email: String,
}
//DATABASE URLconst DB_URL: &str = env!("DATABASE_URL");
//constantsconst OK_RESPONSE: &str =
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, PUT, DELETE\r\nAccess-Control-Allow-Headers: Content-Type\r\n\r\n";
const NOT_FOUND: &str = "HTTP/1.1 404 NOT FOUND\r\n\r\n";
const INTERNAL_ERROR: &str = "HTTP/1.1 500 INTERNAL ERROR\r\n\r\n";
//main functionfnmain() {
//Set DatabaseifletErr(_) = set_database() {
println!("Error setting database");
return;
}
//start server and print portlet listener = TcpListener::bind(format!("0.0.0.0:8080")).unwrap();
println!("Server listening on port 8080");
for stream in listener.incoming() {
match stream {
Ok(stream) => {
handle_client(stream);
}
Err(e) => {
println!("Unable to connect: {}", e);
}
}
}
}
//handle requestsfnhandle_client(mut stream: TcpStream) {
letmut buffer = [0; 1024];
letmut request = String::new();
match stream.read(&mut buffer) {
Ok(size) => {
request.push_str(String::from_utf8_lossy(&buffer[..size]).as_ref());
let (status_line, content) = match &*request {
r if r.starts_with("OPTIONS") => (OK_RESPONSE.to_string(), "".to_string()),
r if r.starts_with("POST /api/rust/users") => handle_post_request(r),
r if r.starts_with("GET /api/rust/users/") => handle_get_request(r),
r if r.starts_with("GET /api/rust/users") => handle_get_all_request(r),
r if r.starts_with("PUT /api/rust/users/") => handle_put_request(r),
r if r.starts_with("DELETE /api/rust/users/") => handle_delete_request(r),
_ => (NOT_FOUND.to_string(), "404 not found".to_string()),
};
stream.write_all(format!("{}{}", status_line, content).as_bytes()).unwrap();
}
Err(e) => eprintln!("Unable to read stream: {}", e),
}
}
//handle post requestfnhandle_post_request(request: &str) -> (String, String) {
match (get_user_request_body(request), Client::connect(DB_URL, NoTls)) {
(Ok(user), Ok(mut client)) => {
// Insert the user and retrieve the IDlet row = client
.query_one(
"INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id",
&[&user.name, &user.email]
)
.unwrap();
let user_id: i32 = row.get(0);
// Fetch the created user datamatch client.query_one("SELECT id, name, email FROM users WHERE id = $1", &[&user_id]) {
Ok(row) => {
let user = User {
id: Some(row.get(0)),
name: row.get(1),
email: row.get(2),
};
(OK_RESPONSE.to_string(), serde_json::to_string(&user).unwrap())
}
Err(_) =>
(INTERNAL_ERROR.to_string(), "Failed to retrieve created user".to_string()),
}
}
_ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
}
}
//handle get requestfnhandle_get_request(request: &str) -> (String, String) {
match (get_id(&request).parse::<i32>(), Client::connect(DB_URL, NoTls)) {
(Ok(id), Ok(mut client)) =>
match client.query_one("SELECT * FROM users WHERE id = $1", &[&id]) {
Ok(row) => {
let user = User {
id: row.get(0),
name: row.get(1),
email: row.get(2),
};
(OK_RESPONSE.to_string(), serde_json::to_string(&user).unwrap())
}
_ => (NOT_FOUND.to_string(), "User not found".to_string()),
}
_ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
}
}
//handle get all requestfnhandle_get_all_request(_request: &str) -> (String, String) {
match Client::connect(DB_URL, NoTls) {
Ok(mut client) => {
letmut users = Vec::new();
for row in client.query("SELECT id, name, email FROM users", &[]).unwrap() {
users.push(User {
id: row.get(0),
name: row.get(1),
email: row.get(2),
});
}
(OK_RESPONSE.to_string(), serde_json::to_string(&users).unwrap())
}
_ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
}
}
//handle put requestfnhandle_put_request(request: &str) -> (String, String) {
match
(
get_id(&request).parse::<i32>(),
get_user_request_body(&request),
Client::connect(DB_URL, NoTls),
)
{
(Ok(id), Ok(user), Ok(mut client)) => {
client
.execute(
"UPDATE users SET name = $1, email = $2 WHERE id = $3",
&[&user.name, &user.email, &id]
)
.unwrap();
(OK_RESPONSE.to_string(), "User updated".to_string())
}
_ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
}
}
//handle delete requestfnhandle_delete_request(request: &str) -> (String, String) {
match (get_id(&request).parse::<i32>(), Client::connect(DB_URL, NoTls)) {
(Ok(id), Ok(mut client)) => {
let rows_affected = client.execute("DELETE FROM users WHERE id = $1", &[&id]).unwrap();
//if rows affected is 0, user not foundif rows_affected == 0 {
return (NOT_FOUND.to_string(), "User not found".to_string());
}
(OK_RESPONSE.to_string(), "User deleted".to_string())
}
_ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
}
}
//db setupfnset_database() -> Result<(), PostgresError> {
letmut client = Client::connect(DB_URL, NoTls)?;
client.batch_execute(
"
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
email VARCHAR NOT NULL
)
"
)?;
Ok(())
}
//Get id from request URLfnget_id(request: &str) -> &str {
request.split("/").nth(4).unwrap_or_default().split_whitespace().next().unwrap_or_default()
}
//deserialize user from request body without idfnget_user_request_body(request: &str) -> Result<User, serde_json::Error> {
serde_json::from_str(request.split("\r\n\r\n").last().unwrap_or_default())
}
We are done with the app code. Now it's the turn of Docker.
๐ณ Dockerize the backend
We will build the Rust app directly inside the image. We will use an official Rust image as the base image. We will also use the official Postgres image as a base image for the database.
We will create 2 files:
.dockerignore: to ignore files and folders that we don't want to copy in the image filesystem
rust.dockerfile: to build the Rust image
You can create them using the terminal or your code editor.
touch .dockerignore rust.dockerfile
Your project should look like this:
๐ซ .dockerignore
Open the .dockerignore file and add the following:
**/target
This is to avoid copying the target folder in the image filesystem.
๐ Dockerfile
We will use a multi-stage build. We will have:
a build stage: to build the Rust app
a production stage: to run the Rust app
Open the Dockerfile and add the following (explanations in comments):
# Build stageFROM rust:1.69-buster as builder
WORKDIR /app# Accept the build argumentARG DATABASE_URL
# Make sure to use the ARG in ENVENV DATABASE_URL=$DATABASE_URL
# Copy the source codeCOPY . .# Build the applicationRUN cargo build --release# Production stageFROM debian:buster-slim
WORKDIR /usr/local/binCOPY --from=builder /app/target/release/backend .CMD ["./backend"]
Please note that we use backend as the executable's name. This is the name of the project folder. If you have a different name, please change it.
๐ Update the Docker Compose file
Open the compose.yaml file and add the following content:
depends_on is the dependency on the database container
Notice that the DATABASE_URL build argument is set to postgres://postgres:postgres@db:5432/postgres. db is the name of the service (and the container_name) of the Postgres container so that it will be resolved to the container IP address.
We use the arg property to pass the DATABASE_URL build argument to the Dockerfile.
We also use a named volume, pg_data, to persist the database data.
Now it's time to build the image and run the Rust backend app.
๐๏ธ Build the image and run the Rust backend app
We need 2 more steps:
build the Rust app image
run the Rust app container
๐๏ธ Build the Rust app image
It's time to build the Rust app image. We will use the docker compose build command. This will build the image using the Dockerfile we created before.
(Note: we might type docker compose up, but by doing that, we would skip understanding what's happening. In a nutshell, when we type docker compose up, Docker builds the images if needed and then runs the containers).
docker compose build
This takes time because we are building the Rust app inside the image.
After ~180 seconds (!), we should have the image built. This could be improved by using the cache of the Toml.lock file, but we want to make the example as simple as possible (we still have the whole next.js app to build!).
๐ Run the Rust Container
docker compose up -d rustapp
Now you can check if the container is running:
docker ps -a
Lastly, you can check the postgres database by typing:
docker exec -it db psql -U postgres
\dt
select * from users;
This does mean that the Rust app created the schema (in our case just a single table) and that the database is working.
๐งช Test the application
Since we don't have our frontend yet, we will use Postman to test the backend.
If we go back on the psql command, and we type select * from users;, we should see the three users (we can get inside the container with the command docker exec -it db psql -U postgres):
In the /frontend/src folder, create a new folder called components and inside it create a new file called CardComponent.tsx and add the following content:
Create a file called next.dockerfile in the frontend folder and add the following content (it's directly from the vercel official docker example)
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn build && ls -l /app/.next
# If using npm comment out above and use below instead
# RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
# set hostname to localhost
ENV HOSTNAME "0.0.0.0"
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD ["node", "server.js"]
Now, let's update the compose.yaml file in the project's root, adding the nextapp service.
And now, let's build the image and run the container:
docker compose build
docker compose up -d nextapp
You can check if the 3 containers are running:
docker ps -a
If you have the 3 services running, should be good to go.
Before we wrap up, let's make a final test using the UI.
๐งช Test the frontend
As a final test, we can check if the frontend is working.
To create a new user, add a name and email
We can check the list of users from the UI or directly from the database:
docker exec -it db psql -U postgres
\dt
select * from users;
We can also update a user, or delete on. for example, let's update the user with id 7:
Well done, the tutorial si complete!
๐ Recap
We build a simple yet complete full-stack web app with Rust API, Next.js 14, Serde, Postgres, Docker, docker Compose.
We used Rust to build the backend API, Next.js 14 to build the frontend, Serde to serialize and deserialize the data, Postgres as the database, Docker to containerize the app, and docker Compose to run the app.
If you prefer a video version: https://youtu.be/77RjzJtC_g4
All the code is available for free on GitHub (link in video description).
If you have any questions, comment below or in the video comments