4 min read

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.

I have to change the hardcoded cochrane reference in this workflow file for the repository name, probably... But it's hardcoded in my tfvars also, so maybe it's okay.

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.