Blog

Shuttle: Fastest Way to Deploy a Custom HTTP-Based Application Server?

08 Apr, 2024
Xebia Background Header Wave

Is Shuttle Possibly the Fastest Way to Deploy a Custom HTTP-Based Application Server?

Shuttle's rocket gif

tl;dr

Who:   Focus on easily prototyping your POC or scaling MVP backends in Rust
What:   Provide the fastest possible backend development experience
When:   Alpha March 2022, beta June 2023, …
Where:   Serverless backend framework on their cloud, your cloud, or “in your garage”
Why:   Setting up and wiring all the infrastructure is a time-consuming and error-prone process
How:   If this may be for you, continue on for details …

Let’s find out.

prerequisites

  1. Install Rust
    rustup
    accept the defaults
  2. Install Just
    cargo install just
  3. Setup Editor
    astronvim
    helix
    vscode
    rustrover
    others

Start with a new directory, named whatever you choose for the project. The first thing to do is add the rust-toolchain.toml file, so the first invocation of any ‘cargo’ command will automatically install the specific version we want to use for this project. You’ll notice that I’ve chosen to use a specific date for the nightly tool chain. I typically do this to make certain that the project is still runnable months or even years after it is first set up, and I’m using a nightly tool chain in order to take advantage of some features that have not yet made it into the stable one.

Copy the below into the ‘rust-toolchain.toml’ file in the project root:

[toolchain]
channel = "nightly-2024-02-24"
components = [
  "rust-analyzer",
  "rust-docs",
  "rust-src",
  "rustfmt",
  "clippy",
  "llvm-tools",
]

The second item is a ‘justfile’, used by the tool we installed in the prerequisites. Similar to make, the ‘just’ tool allows a very brief syntax for setting up build tasks with little more than the collection of the command line invocations, and a few file format conventions.

Copy the below ‘justfile’ into the root directory of your project:

set dotenv-load

# list available recipes
list:
  just --list

# watch for file changes then restart the recipe
watch recipe:
  cargo watch -c -s 'just {{recipe}}'

# run shuttle project locally
run:
  cargo shuttle run

# create and deploy a new shuttle project
start:
  cargo shuttle project start && RUST_LOG=cargo_shuttle cargo shuttle deploy --allow-dirty

# update your existing shuttle project
deploy:
  RUST_LOG=cargo_shuttle cargo shuttle deploy --allow-dirty

# restart (clean) your existing shuttle project then deploy
restart:
  cargo shuttle project restart && RUST_LOG=cargo_shuttle cargo shuttle deploy --allow-dirty

# install or update tools
tools:
  cargo install --locked cargo-shuttle
  cargo install --locked just

Now that we have these two files in place, we can begin. I’ve included within the ‘justfile’ all the tasks that we need to get this Shuttle project set up, plus a few convenience "recipes". The first one we’re going to run will load any additional tooling that is required.

Invoke the command below at the root of your project directory.

just tools

Now that you’ve watched all the tooling being installed, which probably took several minutes unless you happened to already have it, we can start with the coding. Create the ‘src’ directory in the root of the project. All the source code will be placed within here. We will also create a ‘dist’ directory, which will hold the files we want distributed with our HTTP server when it’s deployed. For this project, we’ll just put in a simple, hello world REST call, and a static ‘index.html’ page to serve.


For a typical binary executable file we name the source file ‘main.rs’. Let’s start with the basics for a Shuttle app. We’re going to end up with a feature-gated choice to select one of the top three http server frameworks. To their credit, Shuttle appears to "support all of them", so no matter what your choice is, they have the integration crate, and a working example, available for you.

Let’s create our ‘Cargo.toml’ and add the dependency crates (libraries) for the first framework, ‘axum’.

Paste the following into your ‘Cargo.toml’ file in the project root:

[package]
name = "launch"
version = "0.1.0"
edition = "2021"
rust-version = "1.75"
publish = false

[workspace]

[dependencies]
shuttle-runtime = "0.39"
tokio = "1.36"
tracing = "0.1"

axum = { version = "0.7" }
tower-http = { version = "0.5", features = ["fs"] }
shuttle-axum = { version = "0.39" }

After modifying your ‘Cargo.toml’, add the following code to your ‘src/main.rs’:


use axum::{routing::get, Router};
use tower_http::services::ServeDir;

#[shuttle_runtime::main]
async fn main() -> shuttle_axum::ShuttleAxum {
    let router = Router::new()
        .route("/hi-axum", get(hello_world))
        .nest_service("/", ServeDir::new("./dist"));

    Ok(router.into())
}

We need one more configuration file for this. The ‘Shuttle.toml’, also placed in the project root, contains specific directives for the ‘cargo shuttle’ command. You will need to change the "name" to be something that is globally unique, across all of Shuttle’s clients. If you try to deploy it "as-is", Shuttle will inform you the project name is already taken.

Paste the below into your "Shuttle.toml" file, and modify the name:

name = "xebia-launch"

assets = ["dist/*"]

Then, create a ‘index.html’ file under the ‘dist’ directory, and copy the following in (or, your choice of home page HTML code):

<html><body>
    <img src="https://pages.xebia.com/hs-fs/hubfs/Xebia-AWS-Banner.jpg?width=600&name=Xebia-AWS-Banner.jpg" alt="Xebia logo"/>
</body></html>

Finally, let’s try it out locally, use the cargo shuttle run command, invoking it with just run.

Using any web browser on the same machine, hit the "http://localhost:8000/" uri.

The contents of ‘index.html’ should be served.


Now, let’s add an endpoint, which generates the response instead of just emitting a static file. Add the below code into your ‘main.rs’:

use axum::{routing::get, Router};
use tower_http::services::ServeDir;

#[shuttle_runtime::main]
async fn main() -> shuttle_axum::ShuttleAxum {
    let router = Router::new()
        .route("/hi-axum", get(hello_world))
        .nest_service("/", ServeDir::new("./dist"));

    Ok(router.into())
}

async fn hello_world() -> &'static str {
    "Hello, World!"
}

Try the endpoint, and notice…. nothing is there. We’ll have to Ctrl-C kill the just run and restart it, OR, let’s use just watch run to handle restarting automatically whenever we save file changes.

Try the endpoint again, at "http://localhost:8000/hi-axum". It should be working now.

Ok, time to feature-gate things. Change your files’ contents to match the samples below:

‘Cargo.toml’

[package]
name = "launch"
version = "0.1.0"
edition = "2021"
rust-version = "1.75"
publish = false

[workspace]

[dependencies]
shuttle-runtime = "0.39"
tokio = "1.36"
tracing = "0.1"

actix-web = { version = "4.5", optional = true }
actix-files = { version = "0.6", optional = true }
shuttle-actix-web = { version = "0.39", optional = true }

axum = { version = "0.7", optional = true }
tower-http = { version = "0.5", features = ["fs"], optional = true }
shuttle-axum = { version = "0.39", optional = true }

rocket = { version = "0.5", optional = true }
shuttle-rocket = { version = "0.39", optional = true }

[features]
default = []

actix = ["dep:actix-web", "dep:actix-files", "dep:shuttle-actix-web"]
axum = ["dep:axum", "dep:tower-http", "dep:shuttle-axum"]
rocket = ["dep:rocket", "dep:shuttle-rocket"]

‘main.rs’

///////////////// actix /////////////////
#[cfg(feature = "actix")]
use actix_files::Files;
#[cfg(feature = "actix")]
use actix_web::{get, web::ServiceConfig};
#[cfg(feature = "actix")]
use shuttle_actix_web::ShuttleActixWeb;

#[cfg(feature = "actix")]
#[shuttle_runtime::main]
async fn actix_web() -> ShuttleActixWeb<impl FnOnce(&mut ServiceConfig) + Send + Clone + 'static> {
    let config = move |cfg: &mut ServiceConfig| {
        cfg.service(hello_world).service(
            Files::new("/", "./dist")
                .index_file("index.html")
                .prefer_utf8(true),
        );
    };

    Ok(config.into())
}

///////////////// axum /////////////////
#[cfg(feature = "axum")]
use axum::{routing::get, Router};
#[cfg(feature = "axum")]
use tower_http::services::ServeDir;

#[cfg(feature = "axum")]
#[shuttle_runtime::main]
async fn main() -> shuttle_axum::ShuttleAxum {
    let router = Router::new()
        .route("/hi-axum", get(hello_world))
        .nest_service("/", ServeDir::new("./dist"));

    Ok(router.into())
}

///////////////// rocket /////////////////
#[cfg(feature = "rocket")]
use rocket::{
    fs::{relative, NamedFile},
    get, routes,
};
#[cfg(feature = "rocket")]
use std::path::{Path, PathBuf};

#[cfg(feature = "rocket")]
#[get("/<path..>")]
pub async fn serve(mut path: PathBuf) -> Option<NamedFile> {
    path.set_extension("html");
    let mut path = Path::new(relative!("./dist")).join(path);
    if path.is_dir() {
        path.push("index.html");
    }

    NamedFile::open(path).await.ok()
}

#[cfg(feature = "rocket")]
#[shuttle_runtime::main]
async fn rocket() -> shuttle_rocket::ShuttleRocket {
    let rocket = rocket::build().mount("/", routes![hello_world, launch, serve]);

    Ok(rocket.into())
}

///////////////// all of them /////////////////
#[cfg_attr(feature = "actix", get("/hi-actix"))]
#[cfg_attr(feature = "rocket", get("/hi-rocket"))]
async fn hello_world() -> &'static str {
    "Hello, World!"
}

#[cfg(feature = "rocket")]
#[cfg_attr(feature = "rocket", get("/launch"))]
async fn launch() -> &'static str {
    "3... 2... 1... Liftoff!"
}

When done, you will have three choices of http server framework available, all within the same ‘main.rs’. To indicate which one you wish to run, just specify it within the "default" feature. Try changing, and saving, just the default feature to be one of "axum", "actix", or "rocket". Each change should restart and serve that framework. Notice the Shuttle team has attempted to make the code as similar as possible between them. Differences in how the frameworks function, though, necessitates the various return value signatures. Also notice how your IDE of choice probably "dims" the code which is not currently activated by the default feature.


We’re now ready to deploy this to Shuttle. Create an account at "https://shuttle.rs". The web console will provide you with the "API Key" under your named profile page, which you will need to enter once on the command line.

Run just start at your command prompt, again from the project root. The new project will be created on the Shuttle servers, your project code will be zipped up and POSTed, where it will be extracted in a container, built, and run. Incredibly easy. You were able to focus exclusively on your code, essentially ignoring the need for infrastructure, beyond the couple simple steps needed to get Shuttle set up!

Try it out at the url "https://your-project-name.shuttleapp.rs/".


To deploy changes, including wiping out any prior state that may be left hanging around, use just restart. And, if you wish for that to occur automatically after each save of code changes, just watch restart. Try it.

There you have it. You’ve successfully built and deployed a Rust-based web application server, now available to everyone!

Questions?

Get in touch with us to learn more about the subject and related solutions

Explore related posts