Hosting quickly 2 - Dioxus to the web on Fly.io
This is the second article in a series called "Hosting quickly". I want to launch Rust apps quickly and often, and I want to have stable, common infrastructure for all of them.
Today, let's build a frontend and backend in Dioxus and Axum, respectively.
The frontend
Let's keep it extra simple and only create an example Dioxus frontend, for now. I want the newest and the hottest I can get, which seems to be Dioxus Fullstack. Let's set that up in a new frontend/
directory.
I'll add the new frontend/
package as a member to our root workspace. For good measure, I've copied the example Dioxus.toml into the frontend/
folder. Finally, we'll create a frontend/justfile
with two simple commands:
build:
dx build --features web
run: build
dx serve --features ssr --hot-reload --platform desktop
Simply run just frontend/run
from the root and... it works! It's almost like magic! Moving on, let's deploy this to Fly.io. To do that, I'll add a Dockerfile:
FROM rust:1.73-buster AS builder
WORKDIR /usr/src/cochrane
RUN rustup target add wasm32-unknown-unknown \
&& cargo install dioxus-cli --version 0.4.1
COPY . .
RUN cd frontend \
&& dx build --features web --platform web --release
FROM caddy:2.7.5-alpine
COPY --from=builder /usr/src/cochrane/frontend/dist /usr/share/caddy
This works ... sort of. We get a response with all the necessary files, but the page doesn't render anything. Dioxus's SSR server by default only listens to 127.0.0.1
, so we need to make sure it listens to all addresses:
#[cfg(feature = "ssr")]
fn main() {
use tracing_subscriber;
tracing_subscriber::fmt::init();
LaunchBuilder::new(app)
.addr(std::net::SocketAddrV4::new(std::net::Ipv4Addr::new(0, 0, 0, 0), 8080))
.launch();
}
#[cfg(not(feature = "ssr"))]
fn main() {
LaunchBuilder::new(app).launch();
}
This'll do it.
The backend
Let's set up a simple Axum server with one endpoint. Eventually we might want to add the routes from Dioxus's server functions, but we'll leave that for if I ever want to really use it.
This is super simple and we're purposefully keeping this super simple, so I'll speed through the steps here. We'll add a backend/
folder with its own Cargo.toml
, justfile
, and Dockerfile
. The main.rs
will be a straight copy from the example in its docs.
The justfile
:
build:
cargo build
run:
cargo run
docker-build:
cd .. && docker build . -f backend/Dockerfile -t cochrane-backend:dev
docker-run: docker-build
docker run -it -p 8081:3000 cochrane-backend:dev
And the Dockerfile
:
FROM rust:1.73-buster AS builder
WORKDIR /usr/src/cochrane
COPY . .
RUN cargo build --release -p backend
CMD ["/usr/src/cochrane/target/release/backend"]
Running just backend/docker-run
and hitting localhost:8081
works! All we need to do is add a little magic Hyper incantation to allow the server to shut down nicely. We can lift that from the Hyper docs.
Pushing this to Fly.io
Fly.io has recently deprecated their Terraform provider, but I'm insistent on using Fly.io and Terraform (for now). I'll use a lightly-maintained fork for now. There's a few steps to getting this working. First, I'll have to build the Docker images in Github Actions and push them to the Fly registry. Then we'll need a Terraform configuration for a Fly app and machine
Preparing our Fly apps
We first need to add a Fly app to be able to even push the Docker images. I'll do this through adding this to our Terraform file:
variable "fly_token" {
type = string
description = "A Fly.io API token with access to the right organization"
}
terraform {
required_providers {
// ...
fly = {
source = "pi3ch/fly"
version = "0.0.24"
}
}
}
provider "fly" {
fly_api_token = var.fly_token
}
resource "fly_app" "app_backend" {
name = "${var.github_name}-backend"
}
resource "fly_app" "app_frontend" {
name = "${var.github_name}-frontend"
}
As well as adding fly_token = "op://cochrane/fly/token"
to our terraform.tfvars.tpl
. Now we just need to run just --dotenv-filename .just-env infra/apply
to actually create the apps.
Building and pushing the Docker image
Let's start! I've created a Fly account, and gotten myself an access token that I can use to publish to the Docker registry. Since Github can load the secrets from the 1Password vault directly, I don't need to add this token as an Actions Secret, just to my vault.
We'll add a second Github Workflow file, let's call it build.yaml
, that builds and pushes the Docker images.. That's the easy part, so I'm going to start with authenticating with Fly:
name: Build and deploy
on:
workflow_dispatch:
pull_request:
paths:
- 'frontend/**.rs'
- 'frontend/Cargo.toml'
- 'frontend/Dockerfile'
- 'backend/**.rs'
- 'backend/Cargo.toml'
- 'backend/Dockerfile'
jobs:
plan:
runs-on: ubuntu-latest
steps:
- name: Get Fly token
uses: 1password/load-secrets-action@v1
with:
export-env: true
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT }}
FLY_ACCESS_TOKEN: op://cochrane/fly/token
- uses: superfly/flyctl-actions/setup-flyctl@master
- name: Authenticate Fly registry
run: flyctl auth docker
Then we simply add the Docker build-and-push
action steps to the end:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with:
push: true
file: frontend/Dockerfile
tags: registry.fly.io/cochrane-frontend:latest
- name: Build and push
uses: docker/build-push-action@v5
with:
push: true
file: backend/Dockerfile
tags: registry.fly.io/cochrane-backend:latest
Note we don't need Docker's regular username/app syntax here, just the app name.
Configuring our Fly machines
All we need to do to get the app actually running on Fly is create an IP (for the traffic to be routed to) and a machine. The IP is easy:
resource "fly_ip" "ip_backend" {
app = fly_app.app_backend.name
type = "v4"
}
The only sort of complicated thing about the machine itself is that you have to set the image to point specifically at Fly's own registry, and you have to configure the ports appropriately:
resource "fly_machine" "machine_backend" {
app = fly_app.app_backend.name
image = "registry.fly.io/${var.github_name}-backend:latest"
region = "iad"
services = [
{
ports = [
{
port = 443
handlers = ["tls", "http"]
},
{
port = 80
handlers = ["http"]
}
]
internal_port = 3000
protocol = "tcp"
}
]
}
After running just --dotenv-filename .just-env infra/apply
You have a working service! You can find the IP to hit by running echo "fly_ip.ip_backend" | terraform console -var-file=terraform.tfvars
in the infra/
directory, or you can find the app url in your Fly dashboard.
Now copy this over for the frontend and... shock! It's running!
The End
Well... for now. We haven't actually made the frontend and backend talk with each other yet, nor are they doing anything useful. Up next is a quick "intermission" post on the first, and then we're deploying and configuring a Postgres database.
Be sure to subscribe to keep up, and check out cochrane on Github.