Deploy on Fly.io
Run the Portr server on Fly.io as two apps, with the WebSocket tunnel transport, a wildcard custom domain, and Fly Managed Postgres.
Fly.io terminates TLS and proxies HTTP and WebSocket traffic at its edge, so the WebSocket transport fits well: tunnels ride over regular HTTPS, with no host key and no port 2222.
Fly routes an app's traffic by port, not by hostname. Portr serves the admin
dashboard on PORTR_DOMAIN (port 8000) and tunnels on *.PORTR_DOMAIN
(port 8001), and both need port 443 — one app cannot split them. The fix is
to run the same image as two Fly apps sharing one Postgres database:
| App | Command | Internal port | Domain |
|---|---|---|---|
<admin-app> | start admin | 8000 | portr.example.com |
<tunnel-app> | start tunnel | 8001 | *.portr.example.com |
This guide assumes you know the basics from
Server Deployment and have
flyctl installed and logged in.
Bring your own domain
Tunnel URLs are subdomains of PORTR_DOMAIN (https://myapp.portr.example.com),
and *.fly.dev hostnames cannot be wildcarded, so a working deployment needs:
- A domain you own, managed at a DNS provider where you can add records.
- A wildcard record (
*.portr.example.com) pointing at the tunnel app, set up in Add custom domains.
The examples below use portr.example.com; replace it with your own hostname
everywhere.
Create the apps and database
Pick app names and a region (see fly platform regions), then create both apps
and a Managed Postgres cluster in the same region:
When the cluster is ready, attach it to both apps. This stores the connection
string as the PORTR_DB_URL secret on each app:
fly mpg list shows the cluster ID.
SQLite is not an option here: the two apps run on separate machines and cannot share a volume.
App configuration
Save these two files in the root of your Portr checkout, next to the
Dockerfile.
fly.admin.toml:
fly.tunnel.toml:
A few things are deliberate:
PORTR_DOMAINis identical in both files. The admin app uses it for its own URL and for generated client configs; the tunnel app uses it to pull the subdomain out of each request's host.- The tunnel app never stops. Clients hold long-lived WebSocket
connections to it, so
auto_stop_machinesis off. The admin app has no such connections and can stop when idle; Fly starts it again on the next request. PORTR_WS_URLpoints at the tunnel app'sfly.devhostname, which has a Fly certificate from the start. Any hostname under the wildcard, such astunnel.portr.example.com, also works once its certificate is issued.- The tunnel app has no health check. Port
8001answers unknown hosts with a 404, so an HTTP check would always fail.
Deploy
Deploy the admin app first so its migrations run before the tunnel app starts:
Keep the tunnel app at one machine. Tunnel sessions live in memory, so a
second machine would receive requests for subdomains it has no session for.
--ha=false stops fly deploy from creating a standby machine; don't scale
it up with fly scale count.
Check both apps are up:
Add custom domains
Request certificates on each app:
Each command prints the DNS records Fly needs. Add them at your DNS provider:
| Record | Type | Value |
|---|---|---|
portr | CNAME | <admin-app>.fly.dev |
*.portr | A / AAAA | The tunnel app's IPs (from the second command) |
_acme-challenge.portr | CNAME | portr.example.com.<id>.flydns.net (from the second command) |
The _acme-challenge record is required: wildcard certificates can only be
issued through a DNS-01 challenge. Fly also suggests an optional
_acme-challenge.portr record for the admin certificate, with a different
target. Skip it: the name is taken by the wildcard's record, and the admin
certificate validates on its own once the portr CNAME points at the admin app. fly certs setup <hostname> -a <app> prints
the full list of options again, including a CNAME alternative to the A / AAAA
records.
On Cloudflare, create these records with the proxy off (DNS only). Fly issues and serves the certificates itself, so traffic has to reach Fly directly rather than terminate at Cloudflare.
Certificates typically issue within a few minutes. Check progress with:
Verify
https://portr.example.comserves the admin dashboard. The first login creates the superuser.https://anything.portr.example.comreturns the "Unregistered Subdomain" page. That is the tunnel app answering through the wildcard, which is exactly what a working setup looks like before any tunnel is connected.
Generated client configs (from the admin setup page or portr auth set) will
contain:
Start a tunnel and hit its URL:
GitHub login
To enable GitHub OAuth, store the credentials
as secrets on the admin app rather than in fly.admin.toml:
Limitations
TCP tunnels do not work on this setup: the server opens a random public port for each TCP tunnel, and Fly only exposes ports declared in the app config.
Deploy on Railway
Run the Portr server on Railway with the WebSocket tunnel transport, a wildcard custom domain, and a managed PostgreSQL database.
SQLite Backups
Continuously replicate the Portr server's SQLite database to S3-compatible storage with Litestream, and restore it automatically after a lost volume.