Self-hosted developer tunnels inspired by ngrok, composed of a Go reverse proxy/control plane (aporto-server) and a cross-platform CLI (aporto).
- Server (deploys to your VPS)
- Control plane API on a private interface (
/v1/tunnels) for issuing tunnel credentials. - Public HTTP(S) reverse proxy that routes
https://{subdomain}.{domain}to the correct tunnel session. - SQLite metadata store for tunnel definitions and heartbeat timestamps.
- Control plane API on a private interface (
- CLI (runs on developer machines)
- Stores tunnel credentials locally (
~/.config/aporto/config.yaml). - Opens a persistent WebSocket back to the control plane and proxies HTTP requests to a local service.
- Handles retries, heartbeats, and structured logs.
- Stores tunnel credentials locally (
- Build/install
cd cli && go install ./cmd/aporto
- Generate a keypair (first run only)
aporto init
- Copy the printed public key into the server’s
authorized_keysfile.
- Copy the printed public key into the server’s
- Login
aporto login --api-url https://control.example.com
- Stores the control-plane URL and verifies that your key is authorized. No tunnel is created yet.
- Start tunneling / request a tunnel
aporto 3000 --name demo
- First run: the CLI asks the server for a tunnel using the provided name (optional) as the subdomain and saves the assigned ID + secret. Subsequent runs reuse the stored tunnel unless you pass
--nameagain to request a different hostname. Names can be reclaimed while nobody is actively connected; if a tunnel is live under that name, the server will reject the change. - Omit
--nameto get a fresh random subdomain every time (e.g.https://gpdn6w8kzq.example.com). - You can still run
aporto tunnel startexplicitly, or override the target per run viaaporto tunnel start --local-addr http://127.0.0.1:8080.
- First run: the CLI asks the server for a tunnel using the provided name (optional) as the subdomain and saves the assigned ID + secret. Subsequent runs reuse the stored tunnel unless you pass
- Inspect config
aporto status
When the CLI is running, any request to https://subdomain.example.com is reverse-proxied to the local address.
- Set DNS
- Point
control.example.com(or similar) and*.apps.example.comto the host running Docker.
- Point
- Configure server + env
- Edit
deploy/server-config.docker.yaml, setdomainto your tunnel base (e.g.apps.example.com) and generate a strongadmin_token. - Populate
deploy/authorized_keyswith the base64 public keys of developers who should be able to self-provision tunnels (one per line). - Create a
.envfile alongsidedocker-compose.yml(the compose file already defaultsAPORTO_ON_DEMAND_CHECKto the server's internal allow-list endpoint; override it only if you host your own validation service):ACME_EMAIL=ops@example.com APORTO_CONTROL_DOMAIN=control.example.com APORTO_TUNNEL_DOMAIN=apps.example.com # Optional override: # APORTO_ON_DEMAND_CHECK=https://your-allowlist-endpoint
- Edit
- Launch
docker compose up -d --build
- Caddy listens on
80/443, handles HTTPS for both the control domain and any*.apps.example.comhost, and proxies to theaporto-servercontainer on ports9090(control plane) and8080(tunnels). - Certificates are issued on-demand per subdomain and validated via
/v1/tls/allowon the server; if you need different rules, adjustAPORTO_ON_DEMAND_CHECKto point at your own allow-list endpoint.
- Caddy listens on
- Manage data
- SQLite DB and tunnel state live in the
server_datavolume; Caddy stores ACME certs incaddy_data.
- SQLite DB and tunnel state live in the