PORTAL v2

Self-Hosting Guide

This guide is for developers who want their own relay for a single project or team. The portal image serves the dashboard, relay APIs, and tunnel ingress from one public HTTPS origin.

You should have a relay running and accepting tunnel connections in about 10 minutes.

Prerequisites

  • Docker installed on your server
  • A Linux server with a static public IP
  • A domain name you control (e.g. relay.example.com)
  • Inbound 443/tcp open for the dashboard, relay APIs, and SNI tunnel traffic
  • Inbound 53/tcp + 53/udp open for the embedded authoritative DNS

Quick Start

Run the relay with a single Docker command:

mkdir -p ./relay-data
# For a new bind-mount directory on Linux, allow the nonroot container to write.
# Preserve the ownership policy of existing deployments.
sudo chown 65532:65532 ./relay-data
# Optional: place valid fullchain.pem/privatekey.pem in ./relay-data to use a
# manual certificate, only if neither acme-account.key nor acme-registration.json
# is present. Embedded DNS management still runs.
ADMIN_TOKEN=$(openssl rand -hex 32)
# Save ADMIN_TOKEN in your password manager before starting the container.
docker run -d 
  --name portal-relay 
  --restart unless-stopped 
  --cap-add NET_BIND_SERVICE 
  -p 443:443 
  -p 53:53/tcp 
  -p 53:53/udp 
  -e PORTAL_URL=https://relay.example.com 
  -e IDENTITY_PATH=/portal-certs 
  -e ADMIN_TOKEN="$ADMIN_TOKEN" 
  -v $(pwd)/relay-data:/portal-certs 
  ghcr.io/gosuda/portal:2

Replace relay.example.com with your domain. Keep the generated ADMIN_TOKEN; it is required for relay admin and policy access.

With a manual certificate and embedded DNS, a temporary public-IPv4 discovery failure does not block startup. Before the first successful discovery, A records remain uninitialized; the existing ten-minute DNS retry loop retries pending address initialization. After a successful A-record sync, normal refreshes use the three-hour DNS synchronization loop rather than every retry tick. Later discovery failures retain the last known addresses and resume pending retries. Errors applying A-record updates still propagate; only discovery failure is deferred.

Docker Compose Setup

For a more maintainable setup, use Docker Compose:

# compose.yml
services:
  relay:
    image: ghcr.io/gosuda/portal:2
    restart: unless-stopped
    # Binding the default embedded DNS port 53 as a nonroot container.
    cap_add:
      - NET_BIND_SERVICE
    ports:
      - "443:443"
      - "53:53/tcp"
      - "53:53/udp"
    environment:
      PORTAL_URL: https://relay.example.com
      SNI_PORT: "443"
      IDENTITY_PATH: /portal-certs
      ADMIN_TOKEN: ${ADMIN_TOKEN}
    volumes:
      - ./relay-data:/portal-certs

Prepare a new bind-mount directory and provide the saved admin token through .env or an exported ADMIN_TOKEN. On Linux, the nonroot container needs write access to the directory:

mkdir -p ./relay-data
# For a new directory; preserve existing deployments' ownership policy.
sudo chown 65532:65532 ./relay-data
docker compose up -d

Key Environment Variables

VariableDefaultDescription
PORTAL_URLhttps://localhostCanonical public HTTPS origin, including its externally reachable port.
SNI_PORTPORTAL_URL port, else 443Local TCP SNI router listen port; public metadata uses the port from PORTAL_URL.
IDENTITY_PATH./.portal-certsRelay state directory containing identity.json, policy.json, and TLS materials.
ADMIN_TOKENBearer token source for relay admin and policy APIs.
EMBEDDED_DNS_PORT53Embedded authoritative DNS listen port; requires 53/tcp + 53/udp and CAP_NET_BIND_SERVICE in containers.

Optional: Enable Relay-Owned Sui x402 Facilitator

To reserve relay-side x402 support for future control-plane resources, enable the relay-owned facilitator. This is intended for relay-owned charges such as tunnel registration, lease renewal, raw TCP/UDP port allocation, or premium capacity if an operator decides to require them. Payments use Sui mainnet by default; set X402_TESTNET=true for Sui testnet.

environment:
  X402_ENABLED: "true"
  X402_TESTNET: "false"
  X402_PAY_TO: "0x..."

This serves /api/x402/supported, /api/x402/verify, and /api/x402/settle. Portal payments intentionally support only Sui mainnet/testnet USDC through the gasless stablecoin address-balance flow.

Tunnel paid routes do not use these relay settings. Route-level payment enforcement is configured separately by the tunnel with portal expose --x402-pay-to and optional --x402-testnet; relay X402_PAY_TO and X402_TESTNET are reserved for relay-owned control-plane resources.

Connecting Your Tunnel

Point the portal CLI at your relay with the --relays flag:

portal expose --relays https://relay.example.com --discovery=false localhost:3000

The --relays flag accepts a comma-separated list of relay API URLs. If you omit the scheme, https is assumed.

To avoid typing --relays every time, use a shell alias:

alias portal-relay='portal expose --relays https://relay.example.com --discovery=false'
portal-relay localhost:3000

DNS Configuration

The Configuration Reference is the canonical source for embedded DNS settings. This section summarizes the delegation step only.

The relay serves DNS for its own subdomains from the embedded authoritative server (relay.example.com and every name under it, including tunnel hostnames). Delegate the zone to the relay once from your existing DNS management UI:

TypeNameValue
NSrelay.example.comns.relay.example.com
Ans.relay.example.com<your server IP> (glue)

No wildcard record is needed: the relay synthesizes answers for every tunnel hostname. The nameserver name is fixed to ns.<your relay domain>; publish the matching glue A record at the parent zone as shown above. After verifying the delegation, publish the DS exported in the startup log at the parent zone to establish DNSSEC trust. Preserve IDENTITY_PATH/dnssec-csk.json in the persistent identity volume; see the DNSSEC configuration reference for key permissions, parent setup, and recovery requirements.

TLS with ACME

Certificates are issued automatically via ACME DNS-01 against the embedded authoritative DNS server — no DNS provider credentials are required once the delegation above is in place. External managed backends are supported first-class alternatives to embedded DNS. Operators may retain any vendor for the parent zone and delegate only the relay namespace. For an existing external-backend deployment, the retained settings are:

environment:
  ACME_DNS_PROVIDER: cloudflare   # or: gcloud, hetzner, njalla, route53, vultr
  CLOUDFLARE_TOKEN: <your-token>

See the Deployment Guide for full ACME configuration options, credential setup per provider, and managed DNS automation.

Optional: Enable TCP/UDP Tunneling

To relay raw TCP or UDP traffic (game servers, databases, etc.), enable the transports and set a port range:

environment:
  TCP_ENABLED: "true"
  UDP_ENABLED: "true"
  MIN_PORT: "10000"
  MAX_PORT: "10100"
ports:
  - "443:443/udp" # QUIC backhaul; match the public PORTAL_URL port
  - "10000-10100:10000-10100/tcp"
  - "10000-10100:10000-10100/udp"

Allow both the public QUIC backhaul UDP port and the allocated UDP range through the firewall. The TCP dashboard/SNI mapping remains required.

See TCP/UDP Tunneling for usage details.

Troubleshooting

Port already in use

Port 443 is commonly taken by another process. Check what’s listening:

sudo ss -tlnp | grep ':443'

Stop or reconfigure the conflicting service. The bundled public deployment uses TCP 443 by default. A different public port must be included in PORTAL_URL, published by Docker, and reachable by clients; configure SNI_PORT separately only when the local bind port differs.

DNS not resolving

Query the relay’s authoritative server directly first, then through a public resolver:

dig +short @<your server IP> test.relay.example.com
dig +short test.relay.example.com

If the direct query works but the public one does not, the NS delegation at the parent zone is missing or not yet propagated. If both fail, confirm the relay is running and 53/tcp + 53/udp are open.

Firewall blocking connections

Ensure the public HTTPS and DNS ports are open in your cloud provider’s security group or firewall:

# UFW example
sudo ufw allow 443/tcp
sudo ufw allow 53/tcp
sudo ufw allow 53/udp

Certificate errors

If you see TLS errors on the client side, confirm your certificate files are present in IDENTITY_PATH and that fullchain.pem includes the full chain (leaf + intermediates). If using ACME, check the relay logs for DNS provider authentication errors:

docker compose logs relay --tail 50