This guide covers production deployment of Portal Relay on a public domain.
You need:
- A public domain (example:
example.com) - A public Linux server with a static public IP
- Open inbound ports:
443/tcp,4017/tcp - Optional UDP ports (if enabling UDP transport):
4017/udp,50000+/udp(see section 3.2) - Docker and Docker Compose
- A DNS provider account for ACME DNS-01 automation with a supported provider (
cloudflareorroute53)
Set ACME_DNS_PROVIDER to one of the currently supported values:
ACME_DNS_PROVIDER=cloudflare, orACME_DNS_PROVIDER=route53
Both providers keep root and wildcard A records synchronized to the relay public IPv4 and use DNS-01 for certificate issuance.
- Cloudflare Dashboard ->
Websites->Add a Site - Enter your domain (
example.com) - Complete onboarding and apply Cloudflare nameservers at your registrar
- Wait until zone status is
Active
Cloudflare Dashboard -> DNS -> Records:
- Record 1 (root host)
- Type:
A - Name:
@ - Content:
<server-ip> - Proxy status:
DNS only
- Type:
- Record 2 (wildcard)
- Type:
A - Name:
* - Content:
<server-ip> - Proxy status:
DNS only
- Type:
Expected records:
example.com -> <server-ip>*.example.com -> <server-ip>
If you deploy on a non-apex host (for example, PORTAL_URL=https://portal.example.com:8443), create host-scoped records instead:
portal.example.com -> <server-ip>*.portal.example.com -> <server-ip>
Portal derives public lease hostnames from the normalized PORTAL_URL host.
Requests to the exact root host are not served by the wildcard route; they fall back to the admin/API listener.
Cloudflare Dashboard -> My Profile -> API Tokens -> Create Token.
Grant:
Zone:ReadDNS:Edit
Scope:
- Zone resources limited to your target zone (for example,
example.com)
Save this token for CLOUDFLARE_TOKEN.
Create or select a public hosted zone that covers your PORTAL_URL root host and provide Route53 write permissions through either static AWS credentials or ambient AWS credentials (for example, an instance role).
Static credential environment variables:
AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY- Optional
AWS_SESSION_TOKENfor temporary credentials AWS_REGION(for example,us-east-1)
Optional:
AWS_HOSTED_ZONE_ID(when omitted, relay selects a matching public hosted zone by domain suffix)
Equivalent relay flags:
--aws-access-key-id--aws-secret-access-key--aws-session-token--aws-region--aws-hosted-zone-id
/sdk/registercreates a lease and stores the caller-provided reverse token./sdk/connectrequires:lease_idquery parameterX-Portal-Tokenheader- HTTP/1.1
/sdk/renewand/sdk/unregisterrequirelease_id+reverse_token./sdk/connectis hijacked into a long-lived reverse TCP session after validation.
UDP transport is disabled by default. To enable UDP for real-time workloads (game servers, VoIP), complete all steps:
Step 1: Open UDP ports on your VM/host
If running on a cloud VM (AWS EC2, GCP, OCI, etc.), open the required UDP ports in the security group / firewall rules:
4017/udp— QUIC tunnel listener (relay ↔ tunnel)50000-50009/udp— Raw UDP lease ports (adjust count to matchUDP_PORT_COUNT)
Example (UFW, 10 ports):
sudo ufw allow 4017/udp
sudo ufw allow 50000:50009/udpStep 2: Expose UDP ports in Docker
If using Docker with network_mode: host, UDP ports are directly accessible on the host — no additional Docker config needed.
If using bridge networking, map the UDP ports explicitly in docker-compose.yaml:
ports:
- "4017:4017/udp"
- "50000-50009:50000-50009/udp"Step 3: Configure UDP port count in .env
Set UDP_PORT_COUNT to the number of concurrent UDP leases you want to support. Ports are allocated starting from port 50000:
UDP_PORT_COUNT=10 # allocates ports 50000-50009| Variable | Default | Description |
|---|---|---|
UDP_PORT_COUNT |
0 (disabled) |
Number of UDP ports to allocate, starting at port 50000 |
Step 4: Enable UDP in the admin panel
Navigate to /admin, toggle UDP transport to "Enabled", and optionally set a max concurrent UDP lease limit.
Docker note: Use
network_mode: hostfor the portal container to avoid Docker iptables port-mapping overhead. Docker creates one iptables rule per mapped port, so large UDP ranges cause very slow container start/stop. Host networking bypasses this entirely and allows dynamic UDP port allocation. See the nginx-proxy examples for the recommended setup.
UDP buffer tuning (Linux): Increase kernel UDP buffer limits for QUIC performance:
sudo sysctl -w net.core.rmem_max=7500000 sudo sysctl -w net.core.wmem_max=7500000To persist across reboots, add to
/etc/sysctl.confor a file in/etc/sysctl.d/.
- Relay certificates live in
KEYLESS_DIR:fullchain.pemprivatekey.pem
- On non-localhost deployments, ACME DNS-01 uses the configured supported DNS provider to:
- ensure root and wildcard A records point to the current public IP
- provision the relay certificate
- keep DNS and certificate state refreshed over time
PORTAL_URL=https://example.com
BOOTSTRAPS=
DISCOVERY=true
SNI_PORT=443
ADMIN_SECRET_KEY=your-admin-secret
KEYLESS_DIR=./.portal-certs
ACME_DNS_PROVIDER=cloudflare
CLOUDFLARE_TOKEN=cf_xxxxxxxxxxxxxxxxxRoute53 example:
KEYLESS_DIR=./.portal-certs
ACME_DNS_PROVIDER=route53
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=...
AWS_SESSION_TOKEN=...
AWS_REGION=us-east-1
# Optional override
AWS_HOSTED_ZONE_ID=Z1234567890ABCFor non-apex deployments, set PORTAL_URL to the non-apex host value (for example, https://portal.example.com:8443).
PORTAL_URL path/query segments are ignored for route derivation; only the host component is used.
If the relay sits behind a reverse proxy or ingress and you want admin/auth and lease IP tracking to use the original client IP, set:
TRUST_PROXY_HEADERS=trueBy default, forwarded headers are accepted from private, loopback, and link-local proxy source ranges.
If your proxy source addresses are public or you want a stricter allowlist, also set TRUSTED_PROXY_CIDRS.
docker compose upAutomatically redeploy when a new ghcr.io/gosuda/portal:latest image is pushed.
Create deploy_portal.sh in your project directory:
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
docker compose pull
docker compose up -dThe repository includes watch_and_deploy.sh, which polls the remote image digest and runs the deploy script on change.
Environment variables:
| Variable | Default | Description |
|---|---|---|
INTERVAL |
60 |
Poll interval in seconds |
DEPLOY_SCRIPT |
deploy_portal.sh |
Path to deploy script |
DIGEST_FILE |
.portal_image_digest |
File storing the last known digest |
Set WorkingDirectory and ExecStart to the directory where watch_and_deploy.sh and deploy_portal.sh are located:
sudo tee /etc/systemd/system/portal-watcher.service << 'EOF'
[Unit]
Description=Portal Docker Image Watcher
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service
[Service]
Type=simple
User=opc
# Set to the directory containing watch_and_deploy.sh and deploy_portal.sh
WorkingDirectory=<path-to-project>
ExecStart=/bin/bash <path-to-project>/watch_and_deploy.sh
Restart=always
RestartSec=10
Environment=INTERVAL=60
Environment=DEPLOY_SCRIPT=deploy_portal.sh
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now portal-watcherAdjust User to match your environment. Ensure the user belongs to the docker group:
sudo usermod -aG docker opc# Service status
sudo systemctl status portal-watcher
# Live logs
sudo journalctl -u portal-watcher -f
# Today's logs only
sudo journalctl -u portal-watcher --since todayRequired inbound ports:
443/tcp— SNI router (tenant TLS passthrough)4017/tcp— Admin/API listener4017/udp— QUIC tunnel listener (only ifUDP_PORT_COUNT > 0)50000+/udp— Raw UDP lease ports (only ifUDP_PORT_COUNT > 0, adjust range to match count)
UFW example (with 10 UDP ports):
sudo ufw allow 443/tcp
sudo ufw allow 4017/tcp
sudo ufw allow 4017/udp
sudo ufw allow 50000:50009/udp
sudo ufw statusIf relay logs show failed to sufficiently increase receive buffer size, the kernel UDP buffer limit is too low. Apply the sysctl settings from section 3.2.