How to Containerize a Rust Web Server with MongoDB
Using Docker to create a container for a Rust web server with MongoDB as the database
Published on
Jul 19, 2024
Read time
5 min read
Introduction
In the comments of my first YouTube video, a guide to creating a web server using Rust and MongoDB, I was asked how to containerize and ship the app with a MongoDB container?
I thought this topic would be useful for a lot of people, so in this article we’ll explore an approach to getting Rust and MongoDB working together nicely in a multi-container Docker application, using Docker Compose.
If you’d like to skip to my GitHub repo containing the code in this tutorial, click here.
Why Docker with Rust?
First of all, it’s worth noting that many of the problems Docker solves are less problematic in Rust than other languages.
The “works on my machine” problem, which afflicts many other languages, is not such a significant problem in Rust. Rust’s support for cross-compilation means that we can produce binaries for various platforms directly from our development machines.
However, there are plenty of other benefits to containerization—and it’s very convenient to be able to run your application and set up your database in a single command. Let’s learn how!
Project Setup
We’ll begin by creating a new app and installing actix-web
as a dependency.
cargo new web_server code web_server cargo add actix-web@4.8.0
To keep this article evergreen and ensure it continues to work in the future, I’ll be installing specific versions of every crate.
main.rs
Open up your new project in your favourite code editor. We’ll edit the src/main.rs
file so that the main
function returns a simple web server with one endpoint.
use actix_web::{web, App, HttpResponse, HttpServer, Responder};
async fn greet() -> impl Responder {
HttpResponse::Ok().body("Hello, world!")
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
println!("Starting web server...");
let server = HttpServer::new(|| App::new().route("/", web::get().to(greet)))
.bind("0.0.0.0:5001")?
.run();
println!("Server running at http://localhost:5001/");
server.await
}
I won’t explain this code in detail here. If you’d like to find out more, check out my dedicated article on building a web server with Rust.
Now, when you run cargo run
, you should see a log pointing you to http://localhost:5001. If you click this, you should see “Hello, world!” in your browser. It’s working!
Let’s also build a production-ready version of our app, with the following command:
cargo build --release
This creates a stripped-down version of our app binary, which can be found at target/release/web_server
. This will be useful to know when we create our Dockerfile
.
Adding Docker
Before we worry about our database, we’ll first get Docker working with our Rust application.
We’ll start by creating two new files: Dockerfile
and .dockerignore
.
touch Dockerfile .dockerignore
In the .dockerignore
file, we’ll simply exclude target
(the build directory), as we’ll get Docker to build the app.
Next, we’ll write our Dockerfile
:
# STAGE 1: BUILD
# Use the official Rust image as a build stage
FROM rust:1.79 AS builder
# Create a new empty shell project
RUN USER=root cargo new --bin web_server
WORKDIR /web_server
# Copy our manifests
COPY ./Cargo.toml ./Cargo.toml
COPY ./Cargo.lock ./Cargo.lock
# This build step will cache dependencies
RUN cargo build --release
# Remove the dummy source file created by cargo new
RUN rm src/*.rs
# Copy source tree
COPY ./src ./src
# Build for release
RUN cargo build --release
# STAGE 2: EXECUTE
# Use the official Debian image as a base
FROM debian:bookworm-slim
# Copy the build artefact from the build stage
COPY --from=builder /web_server/target/release/web_server /usr/local/bin/web_server
# Expose the port the server will run on
EXPOSE 5001
# Run the web service on container startup
CMD ["web_server"]
If you’re new to Docker, you can find a reference for the instructions (the words in all-caps) here.
We’re building our app in two stages:
- First, using a Rust image, we build the release version of our app binary.
- Next, we copy that binary to a stripped-down version of the Debian Linux distribution,
bookworm-slim
, where we can execute our binary.
Let’s check it’s working. We need to build our container, via docker build
:
docker build -t web_server .
Then we can run this container on port 5001 with docker run
:
docker run -p 5001:5001 web_server
If it’s working, then as before, you should be able to visit http://localhost:5001 and see “Hello, world!”.
But this time, you can also open up the app (install on Mac or Windows) and you’ll see your new container. If you inspect the files in the "Files" tab, you can find your Rust binary in usr/local/bin
!
Adding MongoDB
We’ve successfully containerized our Rust web server, but there’s more work to do. We also want to set up a MongoDB instance at the same time and connect to it inside our app.
To do this, we’ll need to go back to our app and add a couple more dependencies:
mongodb
will help us connect to our MongoDB instance, anddotenv
will allow us to manage environment variables via a.env
file, which we’ll also create now.
cargo add mongodb@3.0.1 dotenv@0.15.0
touch .env
You can add .env
to your .dockerignore
file (and, if you’re using Git, make sure to add it to .gitignore
too).
In this .env
file, you can add the following:
MONGODB_URI=mongodb://localhost:27017
Next, let’s add a connect_db
function to main.rs
and send a ping to the database.
use std::env;
use actix_web::{web, App, HttpResponse, HttpServer, Responder};
use dotenv::dotenv;
use mongodb::{
bson::doc,
options::{ClientOptions, ServerApi, ServerApiVersion},
sync::Client,
};
async fn greet() -> impl Responder {
HttpResponse::Ok().body("Hello, world!")
}
async fn connect_db() -> mongodb::error::Result<()> {
let uri = env::var("MONGODB_URI").expect("MONGODB_URI must be set");
let mut client_options = ClientOptions::parse(uri).await?;
let server_api = ServerApi::builder().version(ServerApiVersion::V1).build();
client_options.server_api = Some(server_api);
let client = Client::with_options(client_options)?;
client
.database("admin")
.run_command(doc! { "ping": 1 })
.await?;
Ok(())
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
dotenv().ok();
match connect_db().await {
Ok(_) => println!("Successfully connected to the database."),
Err(e) => println!("Failed to connect to the database: {:?}", e),
}
println!("Starting web server...");
let server = HttpServer::new(|| App::new().route("/", web::get().to(greet)))
.bind(("0.0.0.0", 5001))
.unwrap()
.run();
println!("Server running at http://localhost:5001/");
server.await
}
(Again, I won’t describe this code in detail. Check out my dedicated article if you want to find out more about using MongoDB in Rust.)
When using docker run
we can reference the .env
file with the --env-file
flag:
docker run --env-file .env -p 5001:5001 web_server
However, this won’t work, because we’re not running an instance of MongoDB. How can we do that?
Docker Compose
Docker has a tool build to help us handle multi-container applications, and that’s Docker Compose. To use this, we’ll need to create an additional docker-compose.yml
file in the root of our project.
touch docker-compose.yml
In this file, we can add our web server and a MongoDB instance as two separate “services”.
We’ll also create a “volume” called mongo_data
, which provides us with persistant storage for the data in our database.
version: "3.8"
services:
web_server:
build: .
container_name: web_server_container
ports:
- "5001:5001"
depends_on:
- mongodb
environment:
MONGODB_URI: "mongodb://mongodb:27017/test?directConnection=true"
mongodb:
image: mongo:7.0.8-jammy
container_name: mongodb_container
ports:
- "27017:27017"
volumes:
- mongo_data:/data/db
volumes:
mongo_data:
We use the build: .
line to tell docker-compose
to build the web_server
container by looking for the Dockerfile
in the root of our project.
We can use depends_on
to ensure that the MongoDB service starts before our Rust binary.
As for mongo:7.0.8-jammy
, this is a Docker image for MongoDB that’s built on the Ubuntu 22.04 LTS operating system, codenamed “Jammy Jellyfish”.
With this file in place, we can now build our multi-container applcation:
docker-compose up --build
If everything’s working, you’ll now see two containers in Docker Desktop. You should be able to visit http://localhost:5001 and the logs should indicate that a connection to the database has been established!
To stop the containers, run:
docker-compose down
If you want to remove any named volumes declared in the volumes section of the docker-compose.yml
file, you can add the -v
. Be warned that named volumes are used to persist data outside of the container’s filesystem, so using the -v
flag will delete this persisted data.
And that’s a wrap. You know have a containerized web server using Rust and MongoDB that’s dead easy to build and ship, and that comes with all the extra benefits of Docker!
To see a GitHub repo containing the code in this tutorial, click here.
Related articles
You might also enjoy...
Implementing a Linked List in Rust
An introduction to data structures in Rust with a simple linked list example
9 min read
How to build an API server with Rust
A step-by-step tutorial for building a scalable Rust HTTP server using Actix and MongoDB
16 min read
Learn Rust by coding a command line Connect 4 game
In this article, we’ll explore how Rust can help us write command line applications by creating a simple version of Connect 4.
14 min read