Edge tunnel gateway
Edge tunnel gateway
This pattern composes Compute, Network, and Block Storage into a self-hosted tunnel endpoint on infrastructure you control.
What this template does#
Provisions a single instance running Pangolin, an open-source tunnel and identity-aware reverse proxy, that exposes private or home-lab services through a public floating IP:
- Compute instance that runs Pangolin, Gerbil (WireGuard), and Traefik in Docker, sized for a modest tunnel surface (1 vCPU and 2 GiB RAM by default)
- Private network, subnet, router, port, and security group; a floating IP on the tunnel endpoint only
- A block volume mounted at
/data, so Pangolin config, Traefik certificates, and tunnel state live on a volume you can grow rather than on the boot disk - cloud-init installs Docker, generates a server secret on first boot, starts the Pangolin stack, and writes setup instructions to
/root/tunnel-admin-credentials
Private services stay behind CGNAT, a home router, or a private subnet with no inbound ports open. A tunnel client (Pangolin Newt) on the private side dials out to the endpoint; Traefik routes authenticated traffic over the WireGuard tunnel to those services.
No credential ships with this template. The instance generates the server secret and initial setup token on first boot.
Honest scope#
This endpoint runs in one region on a VM you operate. It exposes a service through your Quake AI VM; it is not a global edge network and it does not absorb volumetric DDoS traffic. For geographic distribution and edge absorption, front the origin with a third-party CDN.
Alternate engines#
This template leads with Pangolin (AGPL-3.0 community edition, WireGuard tunnel + Traefik routing + identity/SSO layer + dashboard). frp fits when you want a feature-complete TCP/UDP/HTTP tunnel without an access-control layer. rathole fits when you want the lowest resource use for the tunnel half only. Chisel fits when you need tunnels over HTTP/HTTPS through restrictive networks. Those three solve only the tunnel half; Pangolin covers both the tunnel and who-is-allowed-through-it.
Parameters#
| Parameter | Description | Default |
|---|---|---|
key_name | SSH keypair name (must already exist) | No default |
flavor_name | Instance size (Pangolin + Gerbil + Traefik in 2 GiB) | s1a.small |
image_name | Operating system image | Ubuntu-24.04 |
app_name | Display name prefix for resources | edge-tunnel-gateway |
pangolin_version | Pangolin container image tag | 1.18.4 |
gerbil_version | Gerbil WireGuard manager image tag | 1.18.4 |
traefik_version | Traefik image tag | v3.7 |
wireguard_port | UDP port for the WireGuard tunnel plane | 51820 |
wireguard_client_port | Secondary UDP port for tunnel clients | 21820 |
dashboard_port | Dashboard API port (reach via SSH tunnel) | 3001 |
domain | Public hostname for the dashboard; empty serves HTTP on the floating IP | "" |
base_domain | Root domain for exposed resources; derived from domain when empty | Derived |
letsencrypt_email | Email for Let's Encrypt and initial admin login when domain is set | "" |
volume_size | Block volume size in GiB, mounted at /data | 20 |
external_network | External network for floating IP allocation | PublicStatic |
private_cidr | CIDR for the private subnet | 10.42.0.0/24 |
Ports and access#
| Port | Protocol | Purpose |
|---|---|---|
| 22 | TCP | Host SSH for administration |
| 80 | TCP | HTTP entry (Traefik; redirects to HTTPS when domain is set) |
| 443 | TCP | HTTPS entry when TLS is configured |
| 51820 | UDP | WireGuard tunnel control/data (Gerbil) |
| 21820 | UDP | Secondary WireGuard client port |
| 3001 | TCP | Pangolin dashboard API (not opened in the security group; use an SSH tunnel) |
When to use this pattern#
Run your own tunnel endpoint on a VM with a public IP so services behind CGNAT, a home lab, or a private subnet reach the internet without opening inbound ports on the private side. Pangolin adds identity-aware access (SSO, one-time codes, per-resource policies) on top of the WireGuard tunnel.
For TLS termination without tunneling, see the edge reverse proxy template. For L7 attack filtering on a public front door, see the edge WAF template. For API rate limits and key auth, see the API gateway template.
Estimated cost#
Monthly cost estimate
Pricing calculator ↗Sized as a custom package on shared vCPU.
Monthly total for the required template above. Use the configurator below to add optional pieces and see the total update.
What each resource is for
Tunnel
s1a.small · 2 shared vCPU, 2 GiB RAM, 0.5 Gbps
Compute shown per role at custom-package rates ($29/dedicated vCPU, $7.25/shared vCPU, $1/GiB RAM). The headline above is the billed total: the cheaper of a named plan and the custom package, plus add-ons.
Included in baseline
s1a.small
2 shared vCPU, 2 GiB RAM, 0.5 Gbps
Compute + RAM rate basis
2 vCPU + 2 GiB RAM at $29/dedicated vCPU, $7.25/shared vCPU, $1/GiB RAM (regular). Totals apply the flat −$5/mo package promotion.
Block storage (40 GiB)
40 GiB at $0.08/GiB/mo
Public IP (included)
1 included with the custom package
Package promotional discount
Flat −$5.00/mo on the custom package (same promotion as named plans).
Included at no charge
These line items are zero on Quake AI. Many other providers meter them separately.
Data transfer (inbound and outbound)
Unlimited data transfer on every plan; Quake AI does not meter per-GB egress.
AWS, GCP, and Azure meter outbound transfer per GB. DigitalOcean and Hetzner include an allowance on compute plans, then charge overage.
Learn morePrivate networking
Private networks, subnets, Neutron routers, and security groups are included with the plan.
VPC objects are usually free to create elsewhere, but NAT gateways bill hourly plus per-GB processed. Quake AI uses router SNAT with no separate NAT line item.
Control-plane API requests
OpenStack API calls for provisioning and management are included.
Some managed services on other clouds meter API calls or charge for premium control-plane features.
Pricing data last validated: . For current rates, check quake.ai/pricing.
Template source#
This is a validated OpenTofu template.
Show source (7 files)Hide source
locals {
domain_parts = var.domain != "" ? split(".", var.domain) : []
base_domain = var.base_domain != "" ? var.base_domain : (
length(local.domain_parts) > 2 ? join(".", slice(local.domain_parts, 1, length(local.domain_parts))) : (
var.domain != "" ? var.domain : "local"
)
)
dashboard_host = var.domain != "" ? var.domain : openstack_networking_floatingip_v2.tunnel.address
use_tls = var.domain != "" ? "true" : "false"
}
data "openstack_images_image_v2" "os" {
name = var.image_name
most_recent = true
}
data "openstack_networking_network_v2" "external" {
name = var.external_network
}
resource "openstack_networking_network_v2" "private" {
name = "${var.app_name}-net"
admin_state_up = true
}
resource "openstack_networking_subnet_v2" "private" {
name = "${var.app_name}-subnet"
network_id = openstack_networking_network_v2.private.id
cidr = var.private_cidr
ip_version = 4
dns_nameservers = ["1.1.1.1", "8.8.8.8"]
}
resource "openstack_networking_router_v2" "main" {
name = "${var.app_name}-router"
external_network_id = data.openstack_networking_network_v2.external.id
}
resource "openstack_networking_router_interface_v2" "private" {
router_id = openstack_networking_router_v2.main.id
subnet_id = openstack_networking_subnet_v2.private.id
}
resource "openstack_networking_secgroup_v2" "tunnel" {
name = "${var.app_name}-sg"
description = "SSH, HTTP/HTTPS, and WireGuard for the tunnel gateway endpoint"
}
resource "openstack_networking_secgroup_rule_v2" "ssh" {
direction = "ingress"
ethertype = "IPv4"
protocol = "tcp"
port_range_min = 22
port_range_max = 22
remote_ip_prefix = "0.0.0.0/0"
security_group_id = openstack_networking_secgroup_v2.tunnel.id
}
resource "openstack_networking_secgroup_rule_v2" "http" {
direction = "ingress"
ethertype = "IPv4"
protocol = "tcp"
port_range_min = 80
port_range_max = 80
remote_ip_prefix = "0.0.0.0/0"
security_group_id = openstack_networking_secgroup_v2.tunnel.id
}
resource "openstack_networking_secgroup_rule_v2" "https" {
direction = "ingress"
ethertype = "IPv4"
protocol = "tcp"
port_range_min = 443
port_range_max = 443
remote_ip_prefix = "0.0.0.0/0"
security_group_id = openstack_networking_secgroup_v2.tunnel.id
}
resource "openstack_networking_secgroup_rule_v2" "wireguard" {
direction = "ingress"
ethertype = "IPv4"
protocol = "udp"
port_range_min = var.wireguard_port
port_range_max = var.wireguard_port
remote_ip_prefix = "0.0.0.0/0"
security_group_id = openstack_networking_secgroup_v2.tunnel.id
}
resource "openstack_networking_secgroup_rule_v2" "wireguard_client" {
direction = "ingress"
ethertype = "IPv4"
protocol = "udp"
port_range_min = var.wireguard_client_port
port_range_max = var.wireguard_client_port
remote_ip_prefix = "0.0.0.0/0"
security_group_id = openstack_networking_secgroup_v2.tunnel.id
}
resource "openstack_networking_port_v2" "tunnel" {
name = "${var.app_name}-port"
network_id = openstack_networking_network_v2.private.id
security_group_ids = [openstack_networking_secgroup_v2.tunnel.id]
fixed_ip {
subnet_id = openstack_networking_subnet_v2.private.id
}
depends_on = [openstack_networking_router_interface_v2.private]
}
resource "openstack_blockstorage_volume_v3" "data" {
name = "${var.app_name}-data"
size = var.volume_size
}
resource "openstack_networking_floatingip_v2" "tunnel" {
pool = var.external_network
}
resource "openstack_compute_instance_v2" "tunnel" {
name = var.app_name
flavor_name = var.flavor_name
key_pair = var.key_name
user_data = templatefile("${path.module}/cloud-init/tunnel.yaml.tftpl", {
badger_version = var.badger_version
base_domain = local.base_domain
dashboard_host = local.dashboard_host
dashboard_port = var.dashboard_port
domain = var.domain
floating_ip = openstack_networking_floatingip_v2.tunnel.address
gerbil_version = var.gerbil_version
letsencrypt_email = var.letsencrypt_email
pangolin_version = var.pangolin_version
traefik_version = var.traefik_version
use_tls = local.use_tls
wireguard_client_port = var.wireguard_client_port
wireguard_port = var.wireguard_port
})
block_device {
uuid = data.openstack_images_image_v2.os.id
source_type = "image"
destination_type = "volume"
volume_size = 20
boot_index = 0
delete_on_termination = true
}
network {
port = openstack_networking_port_v2.tunnel.id
}
}
resource "openstack_compute_volume_attach_v2" "data" {
instance_id = openstack_compute_instance_v2.tunnel.id
volume_id = openstack_blockstorage_volume_v3.data.id
}
resource "openstack_networking_floatingip_associate_v2" "tunnel" {
floating_ip = openstack_networking_floatingip_v2.tunnel.address
port_id = openstack_networking_port_v2.tunnel.id
}
variable "key_name" {
description = "SSH keypair name (must already exist in your project)"
type = string
}
variable "flavor_name" {
description = "Instance size. Pangolin, Gerbil, and Traefik fit in 2 GiB for a modest tunnel surface; raise the flavor when you front many sites or run high concurrent tunnel sessions."
type = string
default = "s1a.small"
}
variable "image_name" {
description = "Operating system image. cloud-init targets a Debian-family distribution; Ubuntu 24.04 is the recommended base."
type = string
default = "Ubuntu-24.04"
}
variable "app_name" {
description = "Display name prefix for compute and network resources"
type = string
default = "edge-tunnel-gateway"
}
variable "pangolin_version" {
description = "Pangolin container image tag. The default tracks the current community release; pin a specific tag for reproducible rebuilds."
type = string
default = "1.18.4"
}
variable "gerbil_version" {
description = "Gerbil WireGuard manager container image tag. Match the Pangolin release family unless Pangolin docs specify otherwise."
type = string
default = "1.18.4"
}
variable "traefik_version" {
description = "Traefik reverse-proxy container image tag bundled with Pangolin."
type = string
default = "v3.7"
}
variable "badger_version" {
description = "Traefik badger plugin version Pangolin uses for identity-aware routing."
type = string
default = "v1.3.2"
}
variable "wireguard_port" {
description = "UDP port for the WireGuard tunnel control/data plane (Gerbil). Open this port in the security group."
type = number
default = 51820
}
variable "wireguard_client_port" {
description = "Secondary UDP port Gerbil uses for client connections."
type = number
default = 21820
}
variable "dashboard_port" {
description = "TCP port for the Pangolin dashboard API on localhost. The security group opens only 22, 80, 443, and the WireGuard ports; reach the dashboard through an SSH tunnel to this port."
type = number
default = 3001
}
variable "domain" {
description = "Public hostname for the Pangolin dashboard and tunnel endpoint. When set, Traefik obtains a Let's Encrypt certificate and serves HTTPS on this domain after you point its DNS A record at the floating IP. Leave empty to test over HTTP on the floating IP first."
type = string
default = ""
}
variable "base_domain" {
description = "Root domain for resources you expose through Pangolin (no subdomain). When empty and domain is set, the template derives it by stripping the leftmost label from domain (pangolin.example.com becomes example.com)."
type = string
default = ""
}
variable "letsencrypt_email" {
description = "Email address for Let's Encrypt certificates and the initial admin account. Required when domain is set; ignored when domain is empty."
type = string
default = ""
}
variable "volume_size" {
description = "Block volume size in GiB for Pangolin config, Traefik certificates, and tunnel state. The volume mounts at /data so gateway data lives on a resizable volume rather than the boot disk."
type = number
default = 20
}
variable "external_network" {
description = "Shared external network for router gateway and floating IPs; defaults to PublicStatic (persisted FIP / production pattern). Override with PublicEphemeral for ephemeral demos."
type = string
default = "PublicStatic"
}
variable "private_cidr" {
description = "CIDR for the private tenant network the tunnel endpoint lives in"
type = string
default = "10.42.0.0/24"
}
output "instance_id" {
description = "ID of the compute instance running the tunnel gateway"
value = openstack_compute_instance_v2.tunnel.id
}
output "floating_ip" {
description = "Public floating IP address of the tunnel gateway endpoint"
value = openstack_networking_floatingip_v2.tunnel.address
}
output "private_ip" {
description = "Private IP address of the tunnel endpoint on the tenant network"
value = openstack_compute_instance_v2.tunnel.access_ip_v4
}
output "dashboard_url" {
description = "URL for the Pangolin dashboard. HTTPS on the domain when set, otherwise HTTP on the floating IP."
value = var.domain != "" ? "https://${var.domain}" : "http://${openstack_networking_floatingip_v2.tunnel.address}"
}
output "tunnel_endpoint" {
description = "WireGuard tunnel endpoint clients dial. Newt and other tunnel clients connect to this host and UDP port."
value = "${local.dashboard_host}:${var.wireguard_port}"
}
output "admin_hint" {
description = "How to retrieve the Pangolin setup token and reach the dashboard control plane"
value = "SSH to the instance and read /root/tunnel-admin-credentials for the initial setup token and dashboard URL. Open the dashboard API with an SSH tunnel: ssh -L ${var.dashboard_port}:127.0.0.1:${var.dashboard_port} ubuntu@${openstack_networking_floatingip_v2.tunnel.address}. Register sites, resources, and access policies in the dashboard after initial setup."
}
terraform {
required_version = ">= 1.6.0"
required_providers {
openstack = {
source = "terraform-provider-openstack/openstack"
version = "~> 2.0"
}
}
}
provider "openstack" {}
# Required: SSH keypair must already exist in your project
key_name = "YOUR_KEY_NAME"
# Recommended: set a dashboard domain and point its DNS A record at the floating IP
# from the outputs after apply. Provide letsencrypt_email when domain is set.
# domain = "pangolin.example.com"
# base_domain = "example.com"
# letsencrypt_email = "[email protected]"
# pangolin_version = "1.18.4"
# gerbil_version = "1.18.4"
# traefik_version = "v3.7"
# wireguard_port = 51820
# wireguard_client_port = 21820
# dashboard_port = 3001
# flavor_name = "s1a.small"
# image_name = "Ubuntu-24.04"
# app_name = "edge-tunnel-gateway"
# volume_size = 20
# external_network = "PublicStatic"
# private_cidr = "10.42.0.0/24"
#cloud-config
package_update: true
packages:
- ca-certificates
- curl
- jq
- openssl
write_files:
- path: /opt/tunnel-bootstrap.sh
permissions: "0755"
content: |
#!/usr/bin/env bash
set -euo pipefail
CRED_FILE=/root/tunnel-admin-credentials
STACK_DIR=/data/pangolin
CONFIG_DIR="$STACK_DIR/config"
TRAEFIK_DIR="$CONFIG_DIR/traefik"
DOMAIN="${domain}"
FLOATING_IP="${floating_ip}"
DASHBOARD_HOST="${dashboard_host}"
BASE_DOMAIN="${base_domain}"
LE_EMAIL="${letsencrypt_email}"
USE_TLS="${use_tls}"
DASHBOARD_PORT=${dashboard_port}
WG_PORT=${wireguard_port}
WG_CLIENT_PORT=${wireguard_client_port}
if [ -f "$CRED_FILE" ]; then
cd "$STACK_DIR"
docker compose up -d
exit 0
fi
mkdir -p "$TRAEFIK_DIR" "$CONFIG_DIR/letsencrypt" "$TRAEFIK_DIR/logs"
SERVER_SECRET="$(openssl rand -hex 32)"
if [ "$USE_TLS" = "true" ]; then
DASHBOARD_URL="https://$DASHBOARD_HOST"
SCHEME="https"
else
DASHBOARD_URL="http://$FLOATING_IP"
SCHEME="http"
fi
cat > "$CONFIG_DIR/config.yml" <<CONFIG
gerbil:
start_port: $WG_PORT
base_endpoint: "$DASHBOARD_HOST"
app:
dashboard_url: "$DASHBOARD_URL"
log_level: "info"
telemetry:
anonymous_usage: true
domains:
domain1:
base_domain: "$BASE_DOMAIN"
server:
secret: "$SERVER_SECRET"
cors:
origins: ["$DASHBOARD_URL"]
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
allowed_headers: ["X-CSRF-Token", "Content-Type"]
credentials: false
flags:
require_email_verification: false
disable_signup_without_invite: true
disable_user_create_org: false
allow_raw_resources: true
CONFIG
if [ "$USE_TLS" = "true" ]; then
cat > "$TRAEFIK_DIR/traefik_config.yml" <<TRAEFIK
api:
insecure: true
dashboard: true
providers:
http:
endpoint: "http://pangolin:3001/api/v1/traefik-config"
pollInterval: "5s"
file:
filename: "/etc/traefik/dynamic_config.yml"
experimental:
plugins:
badger:
moduleName: "github.com/fosrl/badger"
version: "${badger_version}"
log:
level: "INFO"
format: "common"
maxSize: 100
maxBackups: 3
maxAge: 3
compress: true
certificatesResolvers:
letsencrypt:
acme:
httpChallenge:
entryPoint: web
email: "$LE_EMAIL"
storage: "/letsencrypt/acme.json"
caServer: "https://acme-v02.api.letsencrypt.org/directory"
entryPoints:
web:
address: ":80"
websecure:
address: ":443"
transport:
respondingTimeouts:
readTimeout: "30m"
http3:
advertisedPort: 443
http:
tls:
certResolver: "letsencrypt"
encodedCharacters:
allowEncodedSlash: true
allowEncodedQuestionMark: true
serversTransport:
insecureSkipVerify: true
ping:
entryPoint: "web"
TRAEFIK
cat > "$TRAEFIK_DIR/dynamic_config.yml" <<DYNAMIC
http:
middlewares:
badger:
plugin:
badger:
disableForwardAuth: true
redirect-to-https:
redirectScheme:
scheme: https
routers:
main-app-router-redirect:
rule: "Host(\`$DASHBOARD_HOST\`)"
service: next-service
entryPoints:
- web
middlewares:
- redirect-to-https
- badger
next-router:
rule: "Host(\`$DASHBOARD_HOST\`) && !PathPrefix(\`/api/v1\`)"
service: next-service
entryPoints:
- websecure
middlewares:
- badger
tls:
certResolver: letsencrypt
api-router:
rule: "Host(\`$DASHBOARD_HOST\`) && PathPrefix(\`/api/v1\`)"
service: api-service
entryPoints:
- websecure
middlewares:
- badger
tls:
certResolver: letsencrypt
ws-router:
rule: "Host(\`$DASHBOARD_HOST\`)"
service: api-service
entryPoints:
- websecure
middlewares:
- badger
tls:
certResolver: letsencrypt
services:
next-service:
loadBalancer:
servers:
- url: "http://pangolin:3002"
api-service:
loadBalancer:
servers:
- url: "http://pangolin:3000"
DYNAMIC
else
cat > "$TRAEFIK_DIR/traefik_config.yml" <<TRAEFIK
api:
insecure: true
dashboard: true
providers:
http:
endpoint: "http://pangolin:3001/api/v1/traefik-config"
pollInterval: "5s"
file:
filename: "/etc/traefik/dynamic_config.yml"
experimental:
plugins:
badger:
moduleName: "github.com/fosrl/badger"
version: "${badger_version}"
log:
level: "INFO"
format: "common"
entryPoints:
web:
address: ":80"
serversTransport:
insecureSkipVerify: true
ping:
entryPoint: "web"
TRAEFIK
cat > "$TRAEFIK_DIR/dynamic_config.yml" <<DYNAMIC
http:
middlewares:
badger:
plugin:
badger:
disableForwardAuth: true
routers:
next-router:
rule: "Host(\`$FLOATING_IP\`)"
service: next-service
entryPoints:
- web
middlewares:
- badger
api-router:
rule: "Host(\`$FLOATING_IP\`) && PathPrefix(\`/api/v1\`)"
service: api-service
entryPoints:
- web
middlewares:
- badger
services:
next-service:
loadBalancer:
servers:
- url: "http://pangolin:3002"
api-service:
loadBalancer:
servers:
- url: "http://pangolin:3000"
DYNAMIC
fi
cat > "$STACK_DIR/docker-compose.yml" <<COMPOSE
name: pangolin
services:
pangolin:
image: docker.io/fosrl/pangolin:${pangolin_version}
container_name: pangolin
restart: unless-stopped
volumes:
- ./config:/app/config
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3001/api/v1/"]
interval: 10s
timeout: 10s
retries: 15
gerbil:
image: docker.io/fosrl/gerbil:${gerbil_version}
container_name: gerbil
restart: unless-stopped
depends_on:
pangolin:
condition: service_healthy
command:
- --reachableAt=http://gerbil:3004
- --generateAndSaveKeyTo=/var/config/key
- --remoteConfig=http://pangolin:3001/api/v1/
volumes:
- ./config/:/var/config
cap_add:
- NET_ADMIN
- SYS_MODULE
ports:
- "$WG_PORT:$WG_PORT/udp"
- "$WG_CLIENT_PORT:$WG_CLIENT_PORT/udp"
- "443:443"
- "80:80"
traefik:
image: docker.io/traefik:${traefik_version}
container_name: traefik
restart: unless-stopped
network_mode: service:gerbil
depends_on:
pangolin:
condition: service_healthy
command:
- --configFile=/etc/traefik/traefik_config.yml
volumes:
- ./config/traefik:/etc/traefik:ro
- ./config/letsencrypt:/letsencrypt
- ./config/traefik/logs:/var/log/traefik
networks:
default:
driver: bridge
name: pangolin_frontend
COMPOSE
cd "$STACK_DIR"
docker compose up -d
SETUP_TOKEN=""
for i in $(seq 1 90); do
SETUP_TOKEN="$(docker compose logs pangolin 2>/dev/null | grep -oE 'setup token[^:]*: [A-Za-z0-9_-]+' | tail -1 | awk '{print $NF}' || true)"
if [ -n "$SETUP_TOKEN" ]; then
break
fi
SETUP_TOKEN="$(docker compose logs pangolin 2>/dev/null | grep -i 'setup token' | tail -1 | sed -n 's/.*token[^A-Za-z0-9_-]*\([A-Za-z0-9_-]\{8,\}\).*/\1/p' || true)"
if [ -n "$SETUP_TOKEN" ]; then
break
fi
sleep 5
done
umask 077
cat > "$CRED_FILE" <<CRED
Pangolin tunnel gateway credentials (generated on first boot)
dashboard_url: $DASHBOARD_URL
tunnel_endpoint: $DASHBOARD_HOST:$WG_PORT (UDP)
setup_token: $${SETUP_TOKEN:-retrieve from: docker compose -f $STACK_DIR/docker-compose.yml logs pangolin}
dashboard_api: http://127.0.0.1:$DASHBOARD_PORT (reach via SSH tunnel only)
Complete initial admin setup at:
$DASHBOARD_URL/auth/initial-setup
Create a site in the dashboard, deploy a Newt client on your private network,
then register resources and access policies through the dashboard.
CRED
chmod 600 "$CRED_FILE"
runcmd:
- |
set -e
DEV=/dev/sdb
for i in $(seq 1 30); do [ -b "$DEV" ] && break; sleep 5; done
if ! blkid "$DEV" >/dev/null 2>&1; then mkfs.ext4 -F -L tunneldata "$DEV"; fi
mkdir -p /data/pangolin
mount "$DEV" /data
grep -q "$DEV" /etc/fstab || echo "$DEV /data ext4 defaults,nofail 0 2" >> /etc/fstab
curl -fsSL https://get.docker.com | sh
systemctl enable --now docker
/opt/tunnel-bootstrap.sh
# Edge tunnel gateway
Single compute instance running [Pangolin](https://github.com/fosrl/pangolin), an open-source tunnel and identity-aware reverse proxy, on infrastructure you control. The endpoint holds the public floating IP; private or home-lab services connect outbound through WireGuard and reach the internet only through this VM.
**Network class:** production — `external_network` defaults to `PublicStatic` for persisted floating IPs and multi-tier stacks; override with `PublicEphemeral` for ephemeral demos.
The instance provisions a private network, a floating IP, and a block volume mounted at `/data` so Pangolin config, Traefik certificates, and tunnel state live on a resizable volume rather than the boot disk. cloud-init installs Docker, starts Pangolin, Gerbil (WireGuard), and Traefik, generates a server secret on first boot, and writes setup instructions to `/root/tunnel-admin-credentials`.
## Where this fits
This template productizes the tunnel and ingress-from-anywhere pattern from the edge and perimeter program: a self-hosted Cloudflare Tunnel plus Access bundle on a VM you operate. It shares the same single-VM edge shape as the [edge reverse proxy](/resources/iac-templates/edge-reverse-proxy), [edge WAF](/resources/iac-templates/edge-waf), and [API gateway](/resources/iac-templates/api-gateway) templates but runs a tunnel server with an identity layer instead of plain TLS termination, L7 filtering, or API routing.
This endpoint runs in one region on a VM you operate. It exposes private services through your Quake AI VM; it is not a global edge network and it does not absorb volumetric DDoS traffic.
## Prerequisites
- OpenTofu >= 1.6.0 or Terraform >= 1.6.0
- Quake AI account with OpenStack credentials
- An existing SSH keypair in your project (the value of `key_name` must match that keypair)
- A domain and DNS A record when you want HTTPS on the dashboard (optional for HTTP-on-FIP testing)
## Resource baseline
Pangolin, Gerbil, and Traefik fit in 2 GiB for a modest tunnel surface. The default `s1a.small` flavor (1 shared vCPU, 2 GiB RAM) handles a few sites and moderate tunnel traffic; raise the flavor when you front many resources or run high concurrent tunnel sessions. The boot disk is 20 GiB; gateway state lives on the separate data volume (`volume_size`, default 20 GiB).
## Usage
1. Clone or copy this template directory
2. Copy `terraform.tfvars.example` to `terraform.tfvars` and set `key_name` (and `domain`, `base_domain`, `letsencrypt_email` when you have a hostname)
3. Source your OpenStack credentials: `source openrc.sh`
4. Initialize: `tofu init`
5. Preview: `tofu plan`
6. Apply: `tofu apply`
cloud-init takes several minutes on first boot to install Docker, mount the data volume, pull Pangolin images, and start the stack. After boot, complete initial admin setup through the dashboard (see Dashboard and tunnel clients below).
## Dashboard and tunnel clients
No admin credential ships in this repository. After apply:
1. SSH to the instance at `floating_ip`
2. Read `/root/tunnel-admin-credentials` for the dashboard URL, tunnel endpoint, and initial setup token
3. Open the dashboard at the URL in that file and complete `/auth/initial-setup`
4. Reach the dashboard API through an SSH tunnel: `ssh -L 3001:127.0.0.1:3001 ubuntu@FLOATING_IP` (port matches `dashboard_port`)
5. Create a site in the dashboard and deploy a [Newt](https://github.com/fosrl/newt) client on your private or home network with the site credentials Pangolin prints
6. Register resources, attach TLS hostnames, and set access policies (SSO, one-time code, or public) in the dashboard
WireGuard listens on UDP `51820` and `21820` by default. Tunnel clients dial the `tunnel_endpoint` output; they do not require inbound ports on the private network.
## Alternate engines
This template leads with Pangolin (AGPL-3.0 community edition, WireGuard tunnel + Traefik routing + identity layer + dashboard). [frp](https://github.com/fatedier/frp) fits when you want a feature-complete TCP/UDP/HTTP tunnel without an identity layer. [rathole](https://github.com/rapiz1/rathole) fits when you want the lowest resource use for the tunnel half only. [Chisel](https://github.com/jpillora/chisel) fits when you need tunnels over HTTP/HTTPS through restrictive networks. Those three solve only the tunnel half; swap the container stack in cloud-init if you prefer one of them.
## Outputs
| Output | Description |
| --- | --- |
| `floating_ip` | Public floating IP of the tunnel gateway endpoint |
| `private_ip` | Private IP of the endpoint on the tenant network |
| `dashboard_url` | HTTPS on the domain when set, otherwise HTTP on the floating IP |
| `tunnel_endpoint` | Host and UDP port tunnel clients dial |
| `admin_hint` | Commands to retrieve setup credentials and open the dashboard |
| `instance_id` | Compute instance ID |
## Validation
This template passes `tofu validate` in CI. That check confirms the OpenTofu configuration is well-formed against the provider schema; it does not run `tofu apply` against a live account.
Resources, parameters, and variables
key_namerequiredflavor_name="s1a.small"image_name="Ubuntu-24.04"app_name="edge-tunnel-gateway"pangolin_version="1.18.4"gerbil_version="1.18.4"traefik_version="v3.7"badger_version="v1.3.2"wireguard_port=51820wireguard_client_port=21820dashboard_port=3001domain=""base_domain=""letsencrypt_email=""volume_size=20external_network="PublicStatic"private_cidr="10.42.0.0/24"
Customize this pattern#
- Customize a template's image and flavor
- Add a block volume to a template
- Parameterize a template with a tfvars file
See also#
Usage Guidelines
The sample code, software libraries, command line tools, proofs of concept, templates, and other related technology on this page (including any of the foregoing that is provided by Quake AI personnel) is provided to you as Quake AI Content under the Quake AI Customer Agreement, or the relevant written agreement between you and Quake AI (whichever applies). Do not use this Quake AI Content in your production accounts, or on production or other critical data. You are responsible for testing, securing, and optimizing the Quake AI Content (such as sample code) as appropriate for production grade use based on your specific quality control practices and standards. Deploying Quake AI Content may incur Quake AI charges for creating or using Quake AI chargeable resources, such as running Compute instances or storing data in Object Storage. Your use is also subject to the Acceptable Use Policy.
For the full policy, see Usage Guidelines.
Last validated: 07.07.2026
See Also
Terraform and OpenTofu on Quake AI
Prerequisite
Networks
Prerequisite
Authoring IaC templates for Quake AI
Shares: Volumes, Security Groups
Deploy an API gateway with the api-gateway template
Shares: Volumes, Security Groups
Deploy a regional edge cache with the edge-cache template
Shares: Volumes, Security Groups