Create a Rust web service, from zero to cloud

Rust is a fantastic general-purpose language with a rich and expressive type system, but one of the reasons the language is so loved is the overall developer experience.

Writing software can be very complex in any language. Working with the language and tools, however, should not be. This is an area where Rust shines!

This tutorial will describe how to...

  • Install Rust
  • Create a new project and manage dependencies
  • Set up a simple web server
  • Compile the app and deploy to a virtual server

I'll be working on Ubuntu 20.04 but most of the setup should be the same on MacOS or a different flavor of Linux.

Getting set up

Installing Rust

Rust is best installed by a shell script that downloads the rustup tool, gives a brief overview on the components that will be installed (as well as where they'll go), and prompts us to confirm options.

➜ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

info: downloading installer

Welcome to Rust!

This will download and install the official compiler for the Rust
programming language, and its package manager, Cargo.

... snip

Current installation options:


   default host triple: x86_64-unknown-linux-gnu
     default toolchain: stable (default)
               profile: default
  modify PATH variable: yes

1) Proceed with installation (default)
2) Customize installation
3) Cancel installation

The defaults are sensible so I'll accept them and let the script do its work...

info: profile set to 'default'
info: default host triple is x86_64-unknown-linux-gnu
info: syncing channel updates for 'stable-x86_64-unknown-linux-gnu'
info: latest update on 2021-07-29, rust version 1.54.0 (a178d0322 2021-07-26)

... snip

Rust is installed now. Great!

To get started you may need to restart your current shell.
This would reload your PATH environment variable to include
Cargo's bin directory ($HOME/.cargo/bin).

To configure your current shell, run:
source $HOME/.cargo/env

... and in about 30 seconds (given 60-70 Mbps download speed) all of Rust's glorious tools will be installed, including:

It also recommends running source $HOME/.cargo/env so the PATH variable in the current shell is updated - future shell sessions should just work, since rustup updates profiles like ~/.bash_profile automatically.

If you're following along and have second thoughts at any point, you can simply run rustup self uninstall to clean your system so that it's free of Rust.

Creating a new project

Now let's create a brand new project that does nothing more than print the typical Hello, world! to the screen.

➜ cargo new hello
     Created binary (application) `hello` package

➜ cd hello

➜ cargo run
   Compiling hello v0.1.0 (/home/kevlarr/projects/know/rust-zero-to-cloud/hello)
    Finished dev [unoptimized + debuginfo] target(s) in 0.56s
     Running `target/debug/hello`
Hello, world!

We cheated a little bit, because cargo automatically creates a "Hello, world!" app for us by scaffolding a project...

.
├── Cargo.lock
├── Cargo.toml
└── src
   └── main.rs

... with a single source file.

fn main() {
    println!("Hello, world!");
}
src/main.rs

We then just cargo run to compile our new app and run the executable at target/debug/hello. We could have created, compiled, and executed the file manually...

➜ echo 'fn main() { println!("Hello, world!"); }' > hello.rs
➜ rustc hello.rs
➜ ./hello

Hello, world!

... but cargo enforces a standard way of laying out a project, managing dependencies, configuring the compiler, and even combining multiple related projects into a workspace.

The most useful cargo commands are:

  • cargo run to check for errors, compile, and then run our app
  • cargo build to check for errors and compile
  • cargo check to only check for errors

Compiling can be pretty slow, so use cargo check while coding for the fastest feedback cycle.

Building a web server

Now let's be nice humans and create a web service that compliments whomever visits our site - feel free to clone the repo.

Adding a framework

We'll start by bootstrapping a new project.

➜ cargo new bee-nice
     Created binary (application) `bee-nice` package
➜ cd bee-nice

Actix Web is one of the most established web frameworks, so let's add that as a dependency to our Cargo.toml manifest file. (Rocket is also an excellent choice, and Axum looks very promising.)

[package]
name = "bee-nice"
version = "0.1.0"
edition = "2018"

[dependencies]
actix-web = "3"
Cargo.toml

And we'll update our app so that it starts a server with a single route.

use std::io;

use actix_web::{get, App, HttpResponse, HttpServer, Responder};

#[get("/")]
async fn hello() -> impl Responder {
    HttpResponse::Ok().body("Hello, world!")
}

#[actix_web::main]
async fn main() -> io::Result<()> {
    HttpServer::new(|| App::new().service(hello))
        .bind("127.0.0.1:8080")?
        .run()
        .await
}
src/main.rs

A few things:

  • #[get("/")] and #[actix_web::main] are attribute-like procedural macros, which transform our code and enable more succinct code than functions could
  • async & await can be complicated, but there's a great 'book' to help

Now we cargo build to download dependencies and then compile our own application code.

➜ cargo build
    Updating crates.io index
  Downloaded pin-project v1.0.8
  Downloaded unicode-bidi v0.3.6
  Downloaded miniz_oxide v0.4.4
  ... lots of other crates
   Compiling proc-macro2 v1.0.28
   Compiling unicode-xid v0.2.2
   Compiling syn v1.0.74
  ... lots of other crates
   Compiling actix-web v3.3.2
   Compiling bee-nice v0.1.0 (/home/kevlarr/projects/bee-nice)
    Finished dev [unoptimized + debuginfo] target(s) in 1m 21s

Most of that 1m 21s was spent compiling dependencies. Unless we remove the target/debug/deps folder or update dependency versions, we won't need to recompile them again.

If we cargo run we won't see much output, since our application isn't logging anything....

➜ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.06s
     Running `target/debug/bee-nice`

... but our browser should at least be able to greet the world now!

Not a case study in good design

Returning a compliment

"Hello, world!" might be polite, but it's still not a compliment to the user.

Rust frameworks tend to be modular, and Actix Web is no exception - there is no built-in HTML templating and no automatic static file serving. We will set those up with the Actix Files plugin and Handlebars template engine.

Again, we'll add our new dependencies (including Serde so we can make a custom object to hold the data for the HTML template) to Cargo.toml.

[package]
name = "bee-nice"
version = "0.1.0"
edition = "2018"

[dependencies]
# We often just select simple versions for our dependencies...
actix-files = "0.5"
actix-web = "3"
serde = "1"

# ... or we might need to opt into certain "features" that aren't
# included by default
handlebars = { version = "3", features = ["dir_source"] }
Cargo.toml

And then we make some big changes to src/main.rs, including...

  • ... updating our server to optionally accept a BIND_ADDRESS environment variable, if we don't want localhost:8080
  • ... adding in a Files service that will map requests for /public/:some/:file/:path to files in the ./web/public directory
  • ... creating a Handlebars instance mapped to the ./web/templates directory and injecting it into application state to be retrieved in request handlers
use std::{env, io};

use actix_files::Files;
use actix_web::{web::Data, get, App, HttpServer, HttpResponse, Responder};
use handlebars::Handlebars;
use serde::Serialize;

#[derive(Serialize)]
struct Compliment {
    adjective: &'static str,
    verb: &'static str,
}

#[get("/")]
async fn compliment(hb: Data<Handlebars<'_>>) -> impl Responder {
    let compliment = Compliment {
        adjective: "most stellar",
        verb: "known and/or dreamed of",
    };
    let html = hb.render("compliment", &compliment).unwrap();

    HttpResponse::Ok()
        .content_type("text/html")
        .body(html)
}

#[actix_web::main]
async fn main() -> io::Result<()> {
    let address = env::var("BIND_ADDRESS")
        .unwrap_or_else(|_err| "localhost:8080".to_string());

    let template_service = {
        let mut handlebars = Handlebars::new();

        handlebars
            .register_templates_directory(".html", "web/templates")
            .unwrap();

        Data::new(handlebars)
    };

    let server = move || App::new()
        .app_data(template_service.clone())
        .service(Files::new("/public", "web/public").show_files_listing())
        .service(compliment);

    HttpServer::new(server)
        .bind(address)?
        .run()
        .await
}
src/main.rs

A few other things here:

  • .unwrap() is a great way to 'ignore' about Result and Option types during development, but we should prefer more robust error handling in production
  • async fn compliment(hb: Data<Handlebars<'_>>) is an example of Actix Web's powerful "extractor" pattern, which allows request handlers to specify what data they want to extract from a request or application state
  • HttpResponse::Ok().content_type(..) is an example of the builder pattern, a useful strategy for overcoming the lack of function overloads, optional arguments, keyword arguments, etc.

Next, create a basic stylesheet and an HTML template with placeholder variables for an adjective and a verb to be supplied at runtime.

<!DOCTYPE html>
<html>
    <head>
        <title>You are the BEST</title>
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <meta charset="UTF-8">
        <link rel="stylesheet" href="/public/main.css">
    </head>
    <body>
        <h1>Hey, you!</h1>
        <p>You are simply the <em>{{ adjective }}</em> person that I have EVER <em>{{ verb }}</em>.</p>
        <p class="small">Don't ever stop being you!</p>
    </body>
</html>
web/templates/compliment.html

Recompile and execute via cargo run and you should see a friendlier site!

Still not a case study in good design...

To the clouds

We typically wrap our applications in a Docker image and deploy to some service that natively runs containers. With languages like Rust or Go that compile directly to binaries, we often don't need that level of abstraction because there's far less to manage when running an application:

  • There's no underlying runtime or VM
  • Dependencies are often statically-linked into the binary
  • Rust doesn't even need to be installed to run an application

For now, let's just deploy our app (which consists of the compiled binary, the HTML template, and the CSS file) to a virtual server running Ubuntu 20.04 that...

  • ... can accept public traffic
  • ... we have root access to via SSH

I'm using a DigitalOcean Droplet because they're simple - and very cheap!

A better binary

Because both my local machine and virtual server are running Ubuntu 20.04, I can simply run...

➜ cargo build --release

... to generate a more-optimized binary. As a comparison, the "release" version is less than 1/10th the size:

➜ find . -name 'bee-nice' -exec ls -lh {} \;
-rwxr-xr-x 1 kevlarr kevlarr 8.1M Aug 19 11:20 ./target/release/bee-nice
-rwxr-xr-x 2 kevlarr kevlarr 100M Aug 19 13:42 ./target/debug/bee-nice

If you are on a different OS than the remote server, you have several options:

  • You can try your hand at cross-compilation (which might require troubleshooting)
  • You can use the official Docker image to compile for a target platform, either with or without running the app inside a container (see the "Compile your app inside the Docker container" section)

Deploying to the remote machine

Assuming we compiled directly (or used Docker just to compile), let's copy over the relevant files into /opt/bee-nice on the remote server, making sure that the binary is in the same directory as the ./web folder.

➜ ssh <USER>@<HOST> "mkdir /opt/bee-nice"
➜ scp target/release/bee-nice <USER>@<HOST>:/opt/bee-nice
➜ scp -r web <USER>@<HOST>:/opt/bee-nice

Now let's remotely start the server in a detached session, binding to 0.0.0.0:80 so any HTTP traffic to our remote server's IP address will go straight to our app. Also, because we naively hard-coded relative paths like web/templates we need to make sure that the current working directory of the process is correct, hence running with cd /opt/bee-nice && ./bee-nice  rather than /opt/bee-nice/bee-nice.

➜ ssh <USER>@<HOST> "cd /opt/bee-nice && BIND_ADDRESS=0.0.0.0:80 screen -d -m ./bee-nice"

While this is a terrible idea for a production application, it's enough for now to show how simple it can be. Notice how there's nothing to install on the remote server, other than our compiled app? Pretty nice - and easy to containerize and deploy properly.

If all works as planned, you should now be able to share a link to your beautiful site with anyone whom you want to make marginally happier!

Conclusion

Rust has modern, well-designed tooling that eases pain points seen in other languages, making it trivial to manage different language versions and pull third-party code into our projects. Additionally, working with compiled binaries can greatly simplify deploying and scaling web services.

Overall, beyond being an enjoyable language, Rust offers an enjoyable experience outside of the code itself, which can be as important to developer happiness - and project success - as having a good type system or an active open-source community.