Skip to content
IaC Templates

Edge tunnel gateway

Template · Updated Jul 2026
Validated Jul 2026

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#

ParameterDescriptionDefault
key_nameSSH keypair name (must already exist)No default
flavor_nameInstance size (Pangolin + Gerbil + Traefik in 2 GiB)s1a.small
image_nameOperating system imageUbuntu-24.04
app_nameDisplay name prefix for resourcesedge-tunnel-gateway
pangolin_versionPangolin container image tag1.18.4
gerbil_versionGerbil WireGuard manager image tag1.18.4
traefik_versionTraefik image tagv3.7
wireguard_portUDP port for the WireGuard tunnel plane51820
wireguard_client_portSecondary UDP port for tunnel clients21820
dashboard_portDashboard API port (reach via SSH tunnel)3001
domainPublic hostname for the dashboard; empty serves HTTP on the floating IP""
base_domainRoot domain for exposed resources; derived from domain when emptyDerived
letsencrypt_emailEmail for Let's Encrypt and initial admin login when domain is set""
volume_sizeBlock volume size in GiB, mounted at /data20
external_networkExternal network for floating IP allocationPublicStatic
private_cidrCIDR for the private subnet10.42.0.0/24

Ports and access#

PortProtocolPurpose
22TCPHost SSH for administration
80TCPHTTP entry (Traefik; redirects to HTTPS when domain is set)
443TCPHTTPS entry when TLS is configured
51820UDPWireGuard tunnel control/data (Gerbil)
21820UDPSecondary WireGuard client port
3001TCPPangolin 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.

Starting template$14.70/mo

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

$16.50/mo

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

$16.50

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

$3.20

Public IP (included)

1 included with the custom package

$0.00

Package promotional discount

Flat −$5.00/mo on the custom package (same promotion as named plans).

$-5.00

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 more
$0.00

Private 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.

$0.00

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.

$0.00

Pricing data last validated: . For current rates, check quake.ai/pricing.

Template source#

This is a validated OpenTofu template.

7 files. Download the zip or expand to copy any file.Download edge-tunnel-gateway.zip
Show source (7 files)
main.tfHCL
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
}
variables.tfHCL
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"
}
outputs.tfHCL
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."
}
versions.tfHCL
terraform {
  required_version = ">= 1.6.0"

  required_providers {
    openstack = {
      source  = "terraform-provider-openstack/openstack"
      version = "~> 2.0"
    }
  }
}

provider "openstack" {}
terraform.tfvars.exampleHCL
# 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-init/tunnel.yaml.tftpl
#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
README.mdMarkdown
# 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
Provisions
Parameterized by
Variables
  • key_namerequired
  • flavor_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=51820
  • wireguard_client_port=21820
  • dashboard_port=3001
  • domain=""
  • base_domain=""
  • letsencrypt_email=""
  • volume_size=20
  • external_network="PublicStatic"
  • private_cidr="10.42.0.0/24"

Customize this pattern#

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

Was this page helpful?