Deploy on Railway
Run the Portr server on Railway with the WebSocket tunnel transport, a wildcard custom domain, and a managed PostgreSQL database.
Railway's edge terminates HTTP and WebSocket traffic but does not expose raw TCP ports on HTTP domains, so the SSH transport is a poor fit there. The WebSocket transport carries tunnels over regular HTTPS connections, which makes Railway a natural host: no host key, no port 2222, and both listeners ride on Railway domains.
This guide assumes you know the basics from Server Deployment.
Bring your own domain
Neither Railway nor Portr provides the domain your tunnels are served on.
Tunnel URLs are subdomains of PORTR_DOMAIN (https://myapp.portr.example.com),
and Railway-generated *.up.railway.app domains have no wildcard support — 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 Railway, set up in Add a wildcard custom domain.
Without your own domain, the admin dashboard and client connections still work on the generated Railway domains, but every tunnel URL will fail to resolve.
One-click template
The fastest path is the official template: it deploys both services with the variables below pre-wired, including a generated Postgres password.
The template cannot bring the domain for you: after it deploys, continue with Add a wildcard custom domain using your own domain — that part is manual either way. The rest of this guide sets the same thing up with Infrastructure as Code or by hand.
Infrastructure as Code
The repository ships a
Railway Infrastructure as Code
file at .railway/railway.ts. It declares the Postgres database and the
portr service with its Dockerfile build, single replica, both custom
domains and every variable from the sections below. It exports the named partial
portr, so applying it only manages those two resources and leaves anything
else in the project alone. It targets the PostgreSQL setup; for
SQLite, declare the volume and mount it on the
service before applying, or the plan will detach it.
Set DOMAIN at the top of the file to your hostname, then from the repository
root:
plan only reads Railway state and prints the changes; apply asks for
confirmation before making them. Variables missing from the file are deleted
on apply, so if you use GitHub login, add
PORTR_ADMIN_GITHUB_CLIENT_ID and PORTR_ADMIN_GITHUB_CLIENT_SECRET as
preserve() entries as the file's comment describes.
The file does not set the service source. Connect the portr service to your fork of the repository (or the upstream repo) in the dashboard, then continue with the DNS records in Add a wildcard custom domain.
Earlier versions of this repository shipped a railway.toml. Railway stops
reading Config as Code files on 2026-12-01, and a service cannot be managed by
both. For an existing deployment, run railway config migrate cutover
(Railway CLI 5.71.0 or newer) before railway config plan, so the service
stops reading railway.toml. railway config migrate undo reverts it.
The sections below set up the same deployment by hand.
Create the services
Create a Railway project with two services:
- Postgres: add a Railway PostgreSQL database.
- portr: point the service at your fork of the Portr repository (or the
upstream repo). The repository's
Dockerfilebuilds the combined admin and tunnel server.
Environment variables
Set these on the portr service:
With PORTR_TRANSPORT=websocket, PORTR_SSH_URL, PORTR_SSH_PORT and
PORTR_SSH_HOST_KEY are not used and can be omitted entirely.
Expose the two listeners
The server listens on two ports, so the service needs two domains with different target ports:
| Domain | Target port | Serves |
|---|---|---|
<admin-domain> | 8000 | Admin dashboard and API |
<proxy-domain> | 8001 | WebSocket tunnel endpoint and tunnel traffic |
Generate both under the service's networking settings, or with the CLI:
At this point the admin dashboard works on the Railway domain, and clients can
connect over WebSocket. Tunnel URLs do not resolve yet: they are served on
subdomains of PORTR_DOMAIN, and Railway domains have no wildcard.
Add a wildcard custom domain
Pick a hostname on a domain you control, for example portr.example.com, and
add two custom domains to the portr service:
Each command prints the DNS records Railway needs. Add them at your DNS provider:
| Record | Type | Value |
|---|---|---|
portr | CNAME | <target>.up.railway.app (from the first command) |
*.portr | CNAME | <target>.up.railway.app (from the second command) |
_acme-challenge.portr | CNAME | <target>.authorize.railwaydns.net (from the second command) |
_railway-verify.portr | TXT | railway-verify=<token> (from railway domain status) |
On Cloudflare, create these records with the proxy off (DNS only).
Railway issues the certificates itself; the _acme-challenge delegation is
how it completes the DNS-01 challenge for the wildcard, and a proxied record
breaks it.
Certificates typically issue within a few minutes. Check progress with:
Then point the server at the custom domain:
PORTR_WS_URL can stay on the Railway-generated proxy domain, or move to any
hostname covered by the wildcard, such as tunnel.portr.example.com. Either
way it must reach port 8001.
Verify
https://portr.example.comserves the admin dashboard.https://anything.portr.example.comreturns the "Unregistered Subdomain" page. That is the tunnel proxy 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:
Response compression and ETags
Railway's edge gzips tunnel responses when the client sends Accept-Encoding,
and marks their ETags as weak ("v1" becomes W/"v1"). Portr passes
responses through unchanged, but a weak ETag cannot satisfy a strict
If-Match check, so an API that uses ETags for optimistic concurrency answers
saves made through the tunnel with 412 Precondition Failed.
Send Cache-Control: no-transform on those responses from your local server.
Railway then skips compression and the strong ETag reaches the client intact.
SQLite instead of PostgreSQL
To skip the Postgres service, attach a volume to the portr service, point
PORTR_DB_URL at a SQLite path on the volume, and see
SQLite Backups for continuous replication to
object storage.