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, and
  • dotenv 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.

© 2024 Bret Cameron