Deploy
Resuma is a long-running Rust process (Axum), not a JavaScript serverless function. Any host that can run a Docker image — Fly.io, DigitalOcean, AWS App Runner, a VM behind Cloudflare, Railway, Render — works. Not Lambda, not Cloudflare Workers. This documentation site runs on Fly.
Deploy runtime
Live process env — same vars you set on Fly, DigitalOcean, or Docker.
What you are shipping
- One binary that listens on
0.0.0.0and the platformPORT. - Built-in probes:
GET /health(liveness) andGET /ready(readiness). RESUMA_ENV=production— sanitized errors, disk rate limits, stricter origin checks.- Behind Fly / App Platform / nginx / Caddy:
RESUMA_TRUST_PROXY=1andRESUMA_TRUSTED_PROXY_CIDRS— without the CIDR list the process refuses to start.
Fastest scaffold: resuma new my-app --template production (Dockerfile + fly.toml). Then pick a host below.
Why there is no Lambda / Workers adapter
Qwik City (including the v2 beta) can ship qwik add aws-lambda because the app is a JavaScript module. Vite emits entry_aws-lambda.tsx that exports a handler(event, context). The same source becomes a Worker fetch handler, a Node listener, or a Netlify/Vercel edge function. Fifteen adapter pages is cheap when each one is a 50-line Vite plugin.
Resuma's entry is FlowApp::serve(): bind a TCP socket, run Tokio + Axum until SIGTERM, write rate-limits and Resuma OS queues to disk. There is no fetch(request) export to wrap. A Lambda/Workers adapter would be a lie — cold start a whole native binary per request, no durable process, /tmp wiped, cron/workers dead. Do not use the AWS Lambda Web Adapter for that reason.
| Qwik City target | Resuma equivalent |
|---|---|
| AWS Lambda adapter | App Runner or ECS Fargate + ALB (same Docker image) |
| Cloudflare Workers / Pages | Workers/Pages: no. Origin on Fly/VPS + Cloudflare DNS, or Cloudflare Containers. Static brochure: resuma build --static-export |
| Vercel / Netlify Edge | No. Those run JS isolates. Use Fly, Cloud Run, or a VM. |
| Google Cloud Run | Yes — same Dockerfile, honor PORT, health /health |
| Node / Deno / Bun / Firebase / Azure SWA | JS runtimes. Skip. Self-host the binary or the image instead. |
| Static / GitHub Pages | resuma build --static-export — no #[server] / #[submit] |
| Self-hosting | Docker + Caddy/nginx, or fly.toml |
Bind address — pick one style
Flow reads RESUMA_ADDR first. If that is unset, it uses HOST + PORT (default 127.0.0.1:3000). When PORT is set, Resuma binds that port exactly — platforms require it.
| Style | When | Trap |
|---|---|---|
HOST=0.0.0.0 + platform PORT | Fly, DigitalOcean, Railway, Render (they inject PORT) | Do not also set RESUMA_ADDR to a different port |
RESUMA_ADDR=0.0.0.0:8080 | Fixed port in Docker / Fly internal_port | Must match the platform HTTP port (DO default is 8080) |
Environment checklist
| Variable | Production |
|---|---|
RESUMA_ENV | production |
HOST / PORT or RESUMA_ADDR | Listen on all interfaces; port = platform HTTP port |
RESUMA_TRUST_PROXY | 1 behind a load balancer |
RESUMA_TRUSTED_PROXY_CIDRS | Required with trust-proxy. Fly: fdaa::/16. Many private fabrics: 10.0.0.0/8. Local nginx on the same machine: 127.0.0.1/32 |
SITE_URL | Public origin, no trailing slash (sitemap, OG tags) |
RESUMA_DATA_DIR | Writable dir for the container user (uid 65532). Use /data — .resuma under /app is not writable after USER 65532. |
CARGO_MANIFEST_DIR | /app so public/ resolves in the image |
RESUMA_EXEC_API_KEY | Only if the app uses .workers() — never commit it |
Full matrix: Environment variables. Check locally with resuma doctor.
Dockerfile (all of the hosts below)
Apps from crates.io do not need Node in the image — loader/core JS is embedded in the resuma crate. Add a Node stage only if you ship ClientComponent TypeScript (this docs site does).
FROM rust:1.91-bookworm AS builder
WORKDIR /app
COPY . .
RUN cargo build --release
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/my-app /app/server
COPY --from=builder /app/public /app/public
# Non-root cannot write /app/.resuma — #[server] actions then 500.
RUN mkdir -p /data && chown 65532:65532 /data
ENV RESUMA_ENV=production
ENV RESUMA_ADDR=0.0.0.0:8080
ENV RESUMA_DATA_DIR=/data
ENV CARGO_MANIFEST_DIR=/app
EXPOSE 8080
USER 65532:65532
CMD ["/app/server"]Replace my-app with the binary name in Cargo.toml. Commit Cargo.lock. Depend on crates.io (or a git tag), not a sibling path = "../resuma" — remote builders will not see that folder.
COPY …/public— Flow serves{CARGO_MANIFEST_DIR}/publicat runtime (icons, CSS, PWA). Without this copy, those URLs 404. SetCARGO_MANIFEST_DIR=/appso the path matchesWORKDIR.RESUMA_DATA_DIR=/datamust be writable by uid 65532. A Fly volume is optional; the directory itself is not. Production rate limits write here. If it is missing, every#[server]action returns 500.- Put
RESUMA_TRUST_PROXYand CIDRs on the platform (fly.toml), not only in the image — otherwise a localdocker runexits. - Native extras belong in the image when the app needs them (e.g. ffmpeg, cmake). Keep the default image slim.
# .dockerignore
/target
.git
.githubdocker build -t my-app .
docker run --rm -p 8080:8080 \
-e RESUMA_TRUSTED_PROXY_CIDRS=127.0.0.1/32 \
-e RESUMA_TRUST_PROXY=1 \
my-app
curl -fsS http://127.0.0.1:8080/healthFly.io
Working Resuma apps on Fly use a fixed RESUMA_ADDR=0.0.0.0:8080 (not HOST+PORT), fdaa::/16, a writable /data, and flyctl deploy --remote-only --ha=false. Examples: youtubetotext, placaqr, underkb. This docs site: resuma-docs.fly.dev.
resuma new my-app --template production
cd my-app
fly launch --no-deploy --ha=false
# Edit fly.toml (snippet below), then:
fly deploy --ha=false
fly open# fly.toml
app = "my-app"
primary_region = "iad"
[build]
dockerfile = "Dockerfile"
[env]
RESUMA_ENV = "production"
RESUMA_TRUST_PROXY = "1"
RESUMA_TRUSTED_PROXY_CIDRS = "fdaa::/16"
RESUMA_ADDR = "0.0.0.0:8080"
RESUMA_DATA_DIR = "/data"
CARGO_MANIFEST_DIR = "/app"
SITE_URL = "https://my-app.fly.dev"
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 0
processes = ["app"]
[[http_service.checks]]
grace_period = "20s"
path = "/health"
interval = "15s"
timeout = "2s"
[[vm]]
size = "shared-cpu-1x"
memory = "512mb"--ha=false— Fly's default is two machines. One is enough and cheaper.grace_period = "20s"— the first request after a Rust cold start is slow; without grace the check kills the machine.min_machines_running = 0+auto_stop_machines— scale to zero (typical SSR app). Workers / cron / SQLite that must keep running:min_machines_running = 1andauto_stop_machines = false, plus a volume (see below).- Optional:
RESUMA_CSP=0if you embed YouTube or other third-party frames;RESUMA_BODY_LIMIT(bytes) for uploads. - Volume (survives restarts):
fly volumes create resuma_data --size 1and infly.toml:
[mounts]
source = "resuma_data"
destination = "/data"Without a volume, /data is still required so rate limits can write. It is just wiped on each new machine.
GitHub Actions (push to main)
Create a deploy token once, store it as the repo secret FLY_API_TOKEN:
fly tokens create deploy -x 999999h -a my-app# .github/workflows/fly.yml
name: Fly Deploy
on:
push:
branches: [main]
workflow_dispatch:
jobs:
deploy:
name: Deploy app
runs-on: ubuntu-latest
concurrency: deploy-group
steps:
- uses: actions/checkout@v4
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --remote-only --ha=false
env:
FLY_API_TOKEN: YOUR_FLY_API_TOKENIn the real file use the GitHub secret expression secrets.FLY_API_TOKEN (the usual Actions secrets.* form). --remote-only builds on Fly. concurrency: deploy-group prevents overlapping deploys. App secrets (RESUMA_EXEC_API_KEY, API keys) stay in fly secrets, not in GitHub env.
DigitalOcean App Platform
App Platform's default HTTP port is 8080. It injects PORT. Bind 0.0.0.0, not localhost. Use a Dockerfile (Rust buildpacks are slower and easier to misconfigure).
# .do/app.yaml — create the app from this spec or paste it in the control panel
name: my-resuma-app
services:
- name: web
dockerfile_path: Dockerfile
http_port: 8080
health_check:
http_path: /health
initial_delay_seconds: 30
period_seconds: 10
envs:
- key: RESUMA_ENV
value: production
- key: HOST
value: "0.0.0.0"
- key: RESUMA_TRUST_PROXY
value: "1"
- key: RESUMA_TRUSTED_PROXY_CIDRS
value: "10.0.0.0/8"
- key: SITE_URL
value: "https://my-resuma-app.ondigitalocean.app"
# Encrypted secrets (API keys) belong in the control panel, not this file.Do not set RESUMA_ADDR=0.0.0.0:3000 in the Dockerfile if App Platform expects 8080 — RESUMA_ADDR wins over PORT and health checks fail.
App Platform disks are ephemeral. Rate-limit files and exec queues reset on each deploy. That is fine for a brochure site. For workers, SQLite, or durable graphs, use a Droplet + volume (or Fly volumes) instead.
DigitalOcean Droplet (Docker + Caddy)
# On the droplet, after docker build:
docker run -d --name my-app --restart unless-stopped \
-p 127.0.0.1:8080:8080 \
-v /var/lib/resuma:/data/resuma \
-e RESUMA_ENV=production \
-e HOST=0.0.0.0 \
-e PORT=8080 \
-e RESUMA_TRUST_PROXY=1 \
-e RESUMA_TRUSTED_PROXY_CIDRS=127.0.0.1/32 \
-e RESUMA_DATA_DIR=/data/resuma \
-e SITE_URL=https://example.com \
my-app
# Caddyfile
example.com {
reverse_proxy 127.0.0.1:8080
}Railway, Render, and similar
Same image. They inject PORT (Render often uses 10000). Set HOST=0.0.0.0, RESUMA_ENV, trust-proxy + a private CIDR (10.0.0.0/8 is the usual starting point), SITE_URL, and an HTTP health path of /health.
# Railway / Render-style env (PORT comes from the platform)
HOST=0.0.0.0
RESUMA_ENV=production
RESUMA_TRUST_PROXY=1
RESUMA_TRUSTED_PROXY_CIDRS=10.0.0.0/8
SITE_URL=https://your-app.up.railway.appAWS
Same Docker image. Resuma is a long-running process — not Lambda, Amplify Hosting, or API Gateway+Node. Pick a container or a VM.
| Service | Fit |
|---|---|
| App Runner | Closest to Fly. Push the image to ECR, port 8080, HTTP health /health. |
| ECS Fargate + ALB | Production default. Mount EFS at /data if queues/SQLite must survive tasks. |
| Lightsail or EC2 | Same as a Droplet: Docker + Caddy/nginx on 127.0.0.1. |
| Lambda | Skip. Cold starts and no durable process. Do not wrap Resuma in the Lambda Web Adapter. |
App Runner (console or CLI) — after docker push to ECR:
# Service
Port: 8080
Health check: HTTP /health (not /)
# Runtime env
RESUMA_ENV=production
RESUMA_ADDR=0.0.0.0:8080
RESUMA_TRUST_PROXY=1
RESUMA_TRUSTED_PROXY_CIDRS=10.0.0.0/8
RESUMA_DATA_DIR=/data
CARGO_MANIFEST_DIR=/app
SITE_URL=https://xxxxx.awsapprunner.comApp Runner injects PORT (default 8080). Keep RESUMA_ADDR on that same port. Give the health check 20s+ to allow a Rust boot. Disk is ephemeral unless you move to ECS+EFS.
ALB / App Runner hop is a private address — 10.0.0.0/8 is the usual RESUMA_TRUSTED_PROXY_CIDRS. If rate-limit IPs look wrong, tighten to your VPC CIDR.
Cloudflare
Resuma does not run on Cloudflare Workers or Pages. Workers are V8 isolates (JS/WASM); Pages is static files. resuma build --static-export can feed Pages for a brochure site with no #[server] / #[submit]. A real app needs a Linux container or a VM, then Cloudflare in front if you want.
DNS / proxy in front of Fly or a VPS (usual path)
Keep the origin on Fly, App Runner, or a Droplet. Point the domain at Cloudflare. Orange-cloud (proxied) means Cloudflare overwrites X-Forwarded-For — set RESUMA_TRUST_PROXY=1 and put Cloudflare's published IP ranges into RESUMA_TRUSTED_PROXY_CIDRS (comma-separated). Grey-cloud (DNS only) leaves TLS to the origin; then you do not need those ranges.
Simpler: Cloudflare Tunnel (cloudflared) on the same machine as the binary, proxy to http://127.0.0.1:8080, and set RESUMA_TRUSTED_PROXY_CIDRS=127.0.0.1/32.
Cloudflare Containers
Paid Workers plan. The same Dockerfile runs as a container; a small Worker fetchs it. defaultPort must be 8080 to match RESUMA_ADDR. Instances sleep when idle (sleepAfter) — /data is wiped, like Fly with no volume. Docs: developers.cloudflare.com/containers.
// src/index.js — Worker that forwards to the Resuma container
import { Container, getContainer } from "@cloudflare/containers";
export class Resuma extends Container {
defaultPort = 8080;
sleepAfter = "10m";
}
export default {
async fetch(request, env) {
return getContainer(env.RESUMA).fetch(request);
},
};{
"name": "my-resuma-app",
"main": "src/index.js",
"compatibility_date": "2026-09-02",
"containers": [
{ "class_name": "Resuma", "image": "./Dockerfile", "max_instances": 2 }
],
"durable_objects": {
"bindings": [{ "class_name": "Resuma", "name": "RESUMA" }]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Resuma"] }]
}Set production env on the container (Wrangler secrets / envVars on the class): RESUMA_ENV, RESUMA_ADDR=0.0.0.0:8080, RESUMA_DATA_DIR=/data, CARGO_MANIFEST_DIR=/app, SITE_URL, and trust-proxy CIDRs for the Cloudflare hop. Then npx wrangler deploy.
GCP, Azure, and the rest
Cloud Run, Azure Container Apps, Google Compute, Azure VM: same image, listen on 0.0.0.0, honor PORT when the platform injects it, health /health, writable /data, RESUMA_TRUST_PROXY=1 + the load-balancer CIDR. If the host can run the Fly Dockerfile, it can run Resuma.
If it does not boot
| Symptom | Cause |
|---|---|
Process exits mentioning RESUMA_TRUSTED_PROXY_CIDRS | RESUMA_TRUST_PROXY=1 without CIDRs |
| Health check timeout / 502 | Bound to 127.0.0.1, or RESUMA_ADDR port ≠ platform HTTP port |
Docker build cannot find resuma | Path dependency; pin crates.io or a git tag |
#[server] / actions return 500 | RESUMA_DATA_DIR missing or not writable by the non-root user |
| Static files / PWA icons 404 | Forgot COPY public or CARGO_MANIFEST_DIR=/app |
| Two machines / surprise bill | Omitted --ha=false on fly launch / fly deploy |
| Health check kills a booting app | Missing grace_period (~20s for a Rust binary) |
← Getting started · Environment variables · Ops & production →