The Full-Stack Rust Challenge
Building a full stack web application using Rust can sometimes make you feel a bit out of place. Let's see if we can make things feel a little more familiar. We have plenty of front-end and back-end frameworks to choose from, but how should we arrange and build a project that includes both a front end and a back end? And how should we package up our app to deploy to production?
We'll cover both of those topics in this video. We're using Yew for the front end and Actix Web for the back end, but the same pattern should be applicable to other frameworks as well. If you'd like more specific details on the front end or the back end, I'll link videos for those topics in the description, but no need to watch those before this video.
Our goal here is to have a setup where we can have a fast feedback loop when developing locally via hot reloading on the front end, but we want Actix Web to serve our static front-end files when we build for production. Then we'll look at how to package our app into a Docker image that we can deploy to some cloud service or elsewhere.
I've seen a few solutions floating around for packaging up a full stack Rust app for deployment to production, but I felt like there's a simpler way. This approach doesn't involve any Makefiles, shell scripts, or reverse proxies. If you've been doing front end development in Rust, you probably already have these, but if you don't already, you'll need to install wasm-bindgen and trunk.
Structuring with a Cargo Workspace
We're actually going to create a Cargo workspace that's going to contain both our front-end and back-end crates. If your front end and back end right now are in separate directory structures, that's fine, you can just move them into this workspace. Or if you're starting from scratch, you'll create both of those crates within this workspace.
The cool thing about this approach is that you can have a third crate in addition to your front end and back end that houses common code that can be shared between your front end and your back end. To create the workspace, we're actually just going to run mkdir. We're going to call it full-stack-rest-app and I'm going to go ahead and copy my front end and my back end crates into this workspace.
I'm going to name the directory for my back end crate backend and the directory for my front end crate frontend. I mentioned a third crate for sharing code between the front end and the back end, I'm going to call that common, so I'm going to create that now: cargo new --lib common. So now I have three crates in this workspace: frontend, backend, and common.
Now let's open this in our IDE. So now you can see we have this nice workspace with three crates in it: backend, common, and frontend. And I've actually already copied over some model files from backend into common so those can be shared in the front end. I'll show you how to add a dependency from backend and frontend on common in a minute.
Now that we have our directory structure in place, the first thing we need to do is create a cargo.toml file for the workspace. In this cargo.toml file, we'll have one section, [workspace], and it's going to specify all the members of the workspace. We are going to have members = ["frontend", "backend", "common"].
default-members allows us to specify a crate that gets built when we run cargo build in the workspace directory. So default-members, and we're going to do backend. Okay, that's all we need to do for our workspace cargo.toml.
Local Development with Trunk Proxy
Now let's look at some changes we might have to make on the front end side of things to accommodate this new setup. Let's add a dependency on the common crate so we can pull in those shared model structures.
Now, when we're developing locally, we want Trunk to serve our front end so we get hot reloading. But when our front end makes requests to the back end, we want Trunk to proxy those requests to Actix Web. So to do that, we'll add the proxy section in the Trunk.toml file. Trunk.toml: [[proxy]], and we say backend = "http://127.0.0.1:80".
So now any API requests our frontend makes will be proxied to Actix Web, which will also be running locally. Before we test this, we also need to add a dependency on common in backend as well. Now we have two terminals and we're going to run Actix Web in one of them in the backend directory, and Trunk in the other in the frontend directory.
So we ran Actix Web with cargo run, and in the front end crate, we're going to run trunk serve so we get hot reloading. Okay, let's test our web app. If you've watched previous videos, you might be familiar with this web app. Long story short, it's a task management web service, and tasks have an ID and they can be in a certain state and be associated with some source and result files.
The flow here is we're making this request to port 8080. Trunk receives that request, serves up our web application. The web application in the browser makes another request to the back end. Trunk receives that request and routes that to Actix Web, and then our backend makes requests to DynamoDB to get the task data, which ultimately winds up coming back to the browser.
Now let's see hot reloading working here. This is a table describing some data in a task. So let's say we want to add a title above this task information. So we'll add an h1 tag here and say Task View. Save the file, switch back to the browser, and just like that, we get hot reloading with Trunk.
Serving Static Files for Production
Now that we have our local development environment set up, let's start thinking about production. In production, Trunk is not in the picture anymore, so we need to tell Actix Web how to serve our HTML files. There is a way to do this with Actix Web out of the box by setting up a service that serves your HTML file and any other files that it might request in that same directory. However, there is a crate called actix-web-lab that contains experimental features that haven't been added to the core Actix Web project yet.
There's a module in actix-web-lab called spa that can help us serve up our static HTML files because that's what this is, it's a single page web application. First, in the backend/cargo.toml, we'll add actix-web-lab as a dependency. And then in main.rs, we'll do use actix_web_lab::spa; and that'll give us this nice convenience function for setting up an SPA service. So then after we define our API services, we're going to do another service, and we're going to say spa(), which is the function that we imported, .index_file("./dist/index.html"), which is a path on disk to our index.html file, and then we're going to do .static_resources_mount("/"), and that's the path at which Actix Web will serve up this single page web application. And then we're going to do .static_resources_location("./dist"), which is the directory to find any other files that index.html might request, and then we're going to do .finish().
The other really important thing to note is that the IP address that you bind your actix web app to should not be 127.0.0.1. It should actually be 0.0.0.0, which allows it to listen on any IP owned by the host. And this is actually important when we run Actix Web in a Docker container. If you specify 127.0.0.1 here, the docker container actually won't be able to expose the Actix Web application.
So now we need to create a symbolic link in the backend directory to the build output of the frontend directory so that Actix Web can serve up our static HTML files that were built by Trunk. So we're going to do ln -s ../frontend/dist ./dist. See if that's there. Okay, it's there.
So now we should be able to use just Actix Web to serve up our entire web application. Let's do cargo run. We should be able to go to the same URL as last time but instead of to port 8080, we go to port 80 because that's where Actix Web is listening. So we're going to paste the previous URL, delete the 8080 and it looks like that works. So now we're just using Actix Web, Trunk is not involved anymore, and this is the setup that we're going to want in production.
Building a Multi-Stage Dockerfile
So now the only thing left to do is to package this up in a Docker image that we can easily deploy to a cloud service or somewhere else. And to do that, we're going to need to create a Dockerfile. So we'll create the Dockerfile in the root directory of our workspace.
So the basics of Docker are outside the scope of this video, but if you want a great introduction to Docker, I'll put a link in the description below. Again, no need to watch that before watching this video, you should still be able to follow along. The way this works is that we're going to have one Docker image for building our application and another Docker image for actually deploying it to production. So first, we need to set up that build image. That build image is going to be based on the official Rust Docker image that has all the Rust build tools pre-installed. So we're going to do FROM rust:latest AS build.
And that's going to grab the latest official Rust Docker image, and doing AS build will later allow us to refer to that image when we're copying files to our production image. Now, the core Rust build tools come pre-installed on this image, but there's a few more things that we need to add. We need to add the WebAssembly target, which doesn't come on the image by default. So rustup target add wasm32-unknown-unknown. And now we need to install trunk and wasm-bindgen.
Now we're going to set our working directory that we're going to copy the files into from our local file system. And now we copy all the files in the current directory of our local system to that working directory on the Docker image. To build our front end, we're going to cd into the frontend directory and run trunk build. We're going to specify the --release flag because this is going to production.
And then because backend is our default crate in the workspace, we actually don't need a CD into the backend directory, we can just run cargo build in the root of the workspace. So RUN cargo build --release. And now our application at this point should be built.
The next part of this is to set up the image that our application is actually going to run on, and this will be based on Google's Distroless image. All you need to know is that it's a hardened image that's meant for running production applications. So to grab that, we're going to do FROM gcr.io/distroless/cc-debian11.
And now there's two build directories that we need to copy to this new image: the backend build artifacts and the frontend build artifacts that came from Trunk. We're going to copy both of those to the user/local/bin directory on this Docker image. We're going to specify the build image that we created up here as the image that we're copying from. And then we're going to do /user/source/full-stack-rust-app/target/release/backend, and again, we're copying that to /user/local/bin on the Docker image.
For the way we've set up our Actix Web application, we'll expect a dist directory parallel to our backend executable. So we're going to copy the front-end build artifacts to dist in user/local/bin. COPY --from=build /user/source/full-stack-rust-app/frontend/dist /user/local/bin/dist. And before we can run our backend executable, we need to set our working directory to /user/local/bin. And then we're going to execute our backend executable. And that should do it for the Docker file. Made a little mistake here, let me fix that: full-stack-rust-app not full-stack-rest.
Running the Production Docker Image
Now that our Dockerfile is set up, we should be able to do docker build in our workspace root directory to build the Docker image. docker build -t task-app .. So we're going to call the image task-app and we're going to use the Dockerfile in the current directory. It can actually take a bit of time to install Trunk and wasm-bindgen on the build image, so just watch out for that.
Now that our docker image is built, we can run it and test it locally. We should be able to navigate to our web app on port 80 in the browser, and the Docker container should serve it to us. Because this web app uses DynamoDB, one thing we need to do when we run the container locally is pass in our AWS credentials so that it can communicate with DynamoDB. Behind the scenes, I've set the AWS_ACCESS_KEY_ID and the AWS_SECRET_ACCESS_KEY environment variables in my terminal, and I'm going to pass the values of those to the Docker container when I run it.
So we're going to do docker run -p 80:80. Which means port 80 on the Docker container will map to port 80 locally. And then we're going to pass it those AWS credentials that I mentioned. So we pass in the ACCESS_KEY_ID, the SECRET_ACCESS_KEY, and the REGION. And then we pass the name of the image that we want to create a container for, which is task-app. Now we should be able to navigate to the same URL that we were before when we were running Actix Web locally.
And there it is. The Docker container is serving our web app. And now we can deploy that Docker image wherever we want to deploy. If you'd like to see a video on deploying that image or maybe automating this build using something like GitHub Actions, let me know in the comments. There you have it, a full stack Rust web app packaged up and ready to deploy. Hope you all liked it and we'll see you in the next one.