Server

Server Deployment

Learn how to deploy the Portr admin dashboard and tunnel server with Docker Compose to self-host your own tunneling infrastructure.

For quick setup, use the production compose file from the Portr repository:

https://github.com/amalshaji/portr/blob/main/docker-compose.prod.yaml

Services Overview

The production compose file includes 3 services:

  • caddy: The reverse proxy for routing and SSL termination
  • server: The combined admin and tunnel server
  • postgres: The PostgreSQL database

Deployment Steps

Download the compose file

Download docker-compose.prod.yaml from the repository and place it in your server directory.

Set up environment variables

Create a .env file in the same directory with the following values:

.env
PORTR_ADMIN_GITHUB_CLIENT_ID= #optional
PORTR_ADMIN_GITHUB_CLIENT_SECRET= #optional

PORTR_DOMAIN=example.com
PORTR_DB_URL=postgres://postgres:<choose-a-strong-secret>@postgres:5432/postgres

PORTR_SERVER_URL=example.com
PORTR_TRANSPORT=ssh
PORTR_SSH_URL=example.com:2222
PORTR_SSH_PORT=2222
PORTR_SSH_HOST_KEY= # PEM-encoded Ed25519 private key
PORTR_WS_URL=tunnel.example.com
PORTR_ADMIN_PORT=8000
PORTR_PROXY_PORT=8001
PORTR_AUTO_MIGRATE=true
PORTR_RESERVED_SUBDOMAIN_LIMIT=3

CLOUDFLARE_API_TOKEN=

POSTGRES_USER=postgres
POSTGRES_PASSWORD=<choose-a-strong-secret>
POSTGRES_DB=postgres

Generate unique credentials for each deployment. Replace every placeholder in the .env file, and use the same database secret in POSTGRES_PASSWORD and PORTR_DB_URL.

Environment Variable Reference

VariableDescriptionDefault
PORTR_DOMAINYour server domainlocalhost:8001
PORTR_DB_URLPostgreSQL connection URLRequired
PORTR_SERVER_URLPublic admin/API server URL for clients, without a schemelocalhost:8000
PORTR_TRANSPORTTransport to emit in generated client configs: ssh or websocketssh
PORTR_SSH_URLPublic SSH tunnel URL for clients when PORTR_TRANSPORT=sshlocalhost:2222
PORTR_SSH_PORTSSH tunnel listener port2222
PORTR_SSH_HOST_KEYStable PEM-encoded Ed25519 host private key when PORTR_TRANSPORT=ssh; strongly recommended to preserve identity across restartsEphemeral key
PORTR_WS_URLWebSocket control hostname routed to the tunnel proxy when PORTR_TRANSPORT=websocketlocalhost:8001
PORTR_ADMIN_PORTAdmin server port8000
PORTR_PROXY_PORTTunnel proxy port; also the WebSocket endpoint port when PORTR_TRANSPORT=websocket8001
PORTR_ADMIN_GITHUB_CLIENT_IDGitHub OAuth client IDOptional
PORTR_ADMIN_GITHUB_CLIENT_SECRETGitHub OAuth client secretOptional
PORTR_RESERVED_SUBDOMAIN_LIMITMaximum reserved subdomains per team membership; use 0 to disable new reservations3
PORTR_AUTO_MIGRATEAuto-run database migrationsfalse
CLOUDFLARE_API_TOKENCloudflare API token for SSLRequired (bundled Caddy)

If you run on SQLite instead of PostgreSQL, see SQLite Backups for the LITESTREAM_* variables that replicate the database to object storage.

If you already run a reverse proxy or manage TLS certificates manually, see Custom Reverse Proxy — the Cloudflare token is only needed by the bundled Caddy service.

Generate SSH Host Key

PORTR_SSH_HOST_KEY is what gives your server a stable SSH identity. If it is unset, the server generates a temporary key in memory each time it starts, so every restart or container recreation presents a different host key. Clients with insecure_skip_host_key_verification: false will then reject the server with "host key verification failed". The client config generated by portr auth set only includes insecure_skip_host_key_verification: false when this variable is set; clients that already have a config file need to add it by hand.

Generate an Ed25519 private key and set it as an environment variable:

ssh-keygen -t ed25519 -f /tmp/portr_host_key -N ""
export PORTR_SSH_HOST_KEY="$(cat /tmp/portr_host_key)"
rm /tmp/portr_host_key /tmp/portr_host_key.pub

You can also generate a key with Portr:

portrd generate-host-key

Store the generated key as a persistent multi-line environment value or Docker secret. Do not generate a new key each time the server starts.

Start the services

Run the following command to start all services:

docker compose -f docker-compose.prod.yaml up -d

The server container runs as a non-root user. If you keep the default ./data bind mount, make sure that directory is writable by the container user before starting the service.

Access the admin dashboard

Once the servers are running, navigate to your domain (e.g., https://example.com) and log in to the admin dashboard. The first login will be treated as a superuser account.

Routing Model

The server process starts one tunnel transport based on PORTR_TRANSPORT:

  • PORTR_ADMIN_PORT serves the admin dashboard and API, including /api/v1/healthcheck.
  • With PORTR_TRANSPORT=ssh, PORTR_SSH_PORT serves SSH tunnel clients.
  • With PORTR_TRANSPORT=websocket, PORTR_PROXY_PORT serves the WebSocket tunnel endpoint at /_portr/tunnel/connect and routes wildcard tunnel traffic such as https://my-app.example.com.

The production compose file routes PORTR_DOMAIN to the admin listener and *.PORTR_DOMAIN to the tunnel listener. PORTR_TRANSPORT controls both the server-side tunnel listener and the generated client config, so clients receive either ssh_url or ws_url to match the running transport.

For WebSocket transport, set PORTR_WS_URL to a hostname covered by the wildcard route, such as tunnel.example.com. Generated configs keep tunnel_url: example.com separate so public application URLs remain https://<subdomain>.example.com.

No database migration is required for the WebSocket tunnel transport itself; the existing connection tables continue to store HTTP and TCP tunnel metadata.

Alternative Database Setup

If you want to run PostgreSQL separately and not as a Docker service, you can exclude the following environment variables from your .env file:

  • POSTGRES_USER
  • POSTGRES_PASSWORD
  • POSTGRES_DB

Troubleshooting

Service Health Checks

Check if all services are running properly:

docker compose -f docker-compose.prod.yaml ps

View Service Logs

Check logs for any specific service:

# View all logs
docker compose -f docker-compose.prod.yaml logs

# View logs for a specific service
docker compose -f docker-compose.prod.yaml logs server
docker compose -f docker-compose.prod.yaml logs caddy
docker compose -f docker-compose.prod.yaml logs postgres

SSL Certificate Issues

If you're having SSL certificate issues, ensure:

  1. Your DNS records are properly configured
  2. Your Cloudflare API token has the correct permissions
  3. Your domain is added to Cloudflare

Port Accessibility

Verify that the required ports are accessible:

# Check the admin/API health endpoint
curl https://your-domain.com/api/v1/healthcheck

# Check wildcard tunnel routing. An unregistered subdomain should return a Portr 404.
curl -I https://unregistered.your-domain.com