Integrating Headscale and Headplane with Pangolin

Integrating Headscale and Headplane with Pangolin

This comprehensive guide will walk you through setting up Headscale (an open-source Tailscale control server) and Headplane (a web UI for Headscale) alongside your existing Pangolin installation. By the end of this guide, you’ll have a fully functional mesh VPN solution integrated with your Pangolin reverse proxy.

Understanding the Architecture

High-Level Architecture

                              ┌─────────────┐
                              │             │
                              │   Traefik   │
                              │             │
                              └──────┬──────┘
                                     │
                 ┌───────────────────┼───────────────────┐
                 │                   │                   │
        ┌────────▼───────┐  ┌────────▼───────┐  ┌────────▼───────┐
        │                │  │                │  │                │
        │    Pangolin    │  │   Headscale    │  │   Headplane    │
        │                │  │                │  │                │
        └────────────────┘  └────────────────┘  └────────────────┘

Before diving into the setup, let’s understand how these components work together:

  1. Pangolin - Self-hosted tunneled reverse proxy with identity management
  2. Headscale - Open-source implementation of the Tailscale control server
  3. Headplane - Web UI for managing Headscale
  4. Traefik - The routing layer that connects everything together

The integration uses Pangolin’s existing Traefik instance to route traffic to both Pangolin and Headscale/Headplane services. This provides a unified management experience for both systems.

Prerequisites

  • A working Pangolin installation with Traefik
  • Docker and Docker Compose
  • A domain for Headscale (we’ll use heads.intranet.hhf.technology)
  • Basic familiarity with Docker networking and Traefik

You can deploy traefik separately for headscale also but more complicated. Let me know if you need that. But this is for broder crowd adaptation.

Part 1: Directory Structure Setup

First, let’s create the necessary directory structure:

# Navigate to your Pangolin installation directory
cd /path/to/pangolin

# Create required directories
mkdir -p headscale/config headscale/data headplane

Part 2: Headscale Configuration

Create the Headscale configuration file:

nano headscale/config/config.yaml

Add the following configuration:

server_url: https://heads.intranet.hhf.technology
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090

# Required prefixes section
prefixes:
  v4: 100.64.0.0/10
  v6: fd7a:115c:a1e0::/48

# DERP configuration
derp:
  server:
    enabled: false
  urls:
    - https://controlplane.tailscale.com/derpmap/default
  auto_update_enabled: true
  update_frequency: 24h

# DNS settings
dns:
  magic_dns: true
  nameservers:
    global:
      - 1.1.1.1
      - 8.8.8.8
  base_domain: ts.hhf.technology

# Database settings
database:
  type: sqlite
  sqlite:
    path: /var/lib/headscale/db.sqlite

# Noise key settings
noise:
  private_key_path: /var/lib/headscale/noise_private.key

# Unix socket
unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770"

log:
  format: text
  level: info

This configuration:

  • Points to your domain (heads.intranet.hhf.technology)
  • Sets up the IP ranges for your Tailscale network
  • Configures DNS for your VPN
  • Sets up storage for the SQLite database
  • Configures logging and other essential parameters

Part 3: Headplane Configuration

Create the Headplane configuration file:

nano headplane/config.yaml

Add the following configuration:

server:
  host: "0.0.0.0"
  port: 3000
  cookie_secret: "GENERATE_A_STRONG_SECRET" # Change this!
  cookie_secure: true

headscale:
  url: "http://headscale:8080"
  config_path: "/etc/headscale/config.yaml"
  config_strict: true

# Integration configurations
integration:
  docker:
    enabled: true
    container_name: "headscale"
    socket: "unix:///var/run/docker.sock"
  kubernetes:
    enabled: false
    validate_manifest: true
    pod_name: "headscale"
  proc:
    enabled: false

Generate a strong cookie secret:

openssl rand -hex 16

Replace GENERATE_A_STRONG_SECRET with the generated value. This secret is used to encrypt session cookies.

Part 4: Traefik Configuration

Let’s update the Traefik configuration to route traffic to Headscale and Headplane.

Create or modify your dynamic configuration file:

nano config/traefik/dynamic_config.yml

Replace its contents with:

http:
  middlewares:
    # Existing middlewares
    redirect-to-https:
      redirectScheme:
        scheme: https

    # Root path redirect middleware for Headscale
    headscale-redirect:
      redirectRegex:
        regex: "^(https?://[^/]+)/?$"
        replacement: "${1}/admin"
        permanent: true
    
    # Admin path rewriting middleware for Headplane
    headscale-rewrite-admin:
      addPrefix:
        prefix: "/admin"
    
    # CORS middleware for Headscale API
    cors:
      headers:
        accessControlAllowMethods:
          - GET
          - OPTIONS
          - PUT
          - POST
        accessControlAllowHeaders:
          - "*"
        accessControlAllowOriginList:
          - "https://heads.intranet.hhf.technology"
        accessControlMaxAge: 100
        addVaryHeader: true
    
    # Basic auth middleware for Traefik dashboard (optional)
    traefik-dashboard-auth:
      basicAuth:
        users:
          # Generate this with: htpasswd -nb admin YOUR_PASSWORD
          - "admin:$apr1$ls1hhnt/$fKLs2zmr51n8RBDlw.MlG."

  routers:
    # Your existing Pangolin routers here
    main-app-router-redirect:
      rule: "Host(`pangolin.development.hhf.technology`)"
      service: next-service
      entryPoints:
        - web
      middlewares:
        - redirect-to-https

    next-router:
      rule: "Host(`pangolin.development.hhf.technology`) && !PathPrefix(`/api/v1`)"
      service: next-service
      entryPoints:
        - websecure
      tls:
        certResolver: letsencrypt

    api-router:
      rule: "Host(`pangolin.development.hhf.technology`) && PathPrefix(`/api/v1`)"
      service: api-service
      entryPoints:
        - websecure
      tls:
        certResolver: letsencrypt

    ws-router:
      rule: "Host(`pangolin.development.hhf.technology`)"
      service: api-service
      entryPoints:
        - websecure
      tls:
        certResolver: letsencrypt
    
    # Headscale API router - for all non-admin paths
    headscale-api-rtr:
      rule: "Host(`heads.intranet.hhf.technology`) && !PathPrefix(`/admin`)"
      service: headscale-direct-svc
      entryPoints:
        - websecure
      tls:
        certResolver: letsencrypt
      middlewares:
        - cors

    # Headplane admin interface router
    headplane-admin-rtr:
      rule: "Host(`heads.intranet.hhf.technology`) && PathPrefix(`/admin`)"
      service: headplane-direct-svc
      entryPoints:
        - websecure
      tls:
        certResolver: letsencrypt

    # Root path redirection router
    headscale-root-rtr:
      rule: "Host(`heads.intranet.hhf.technology`) && Path(`/`)"
      service: noop@internal
      entryPoints:
        - websecure
      tls:
        certResolver: letsencrypt
      middlewares:
        - headscale-redirect
      priority: 100  # Make this higher priority than other rules

    # Tailscale subdomain handler
    headscale-ts-rtr:
      rule: "HostRegexp(`{subdomain:[a-zA-Z0-9-]+}.ts.hhf.technology`)"
      service: headscale-direct-svc
      entryPoints:
        - websecure
      tls:
        certResolver: letsencrypt
    
    # Traefik dashboard access (optional)
    traefik-dashboard-rtr:
      rule: "Host(`traefik.development.hhf.technology`)"
      service: api@internal
      entryPoints:
        - websecure
      tls:
        certResolver: letsencrypt
      middlewares:
        - traefik-dashboard-auth

  services:
    # Your existing Pangolin services
    next-service:
      loadBalancer:
        servers:
          - url: "http://pangolin:3002"  # Next.js server

    api-service:
      loadBalancer:
        servers:
          - url: "http://pangolin:3000"  # API/WebSocket server
    
    # Headscale services
    headscale-direct-svc:
      loadBalancer:
        servers:
          - url: "http://headscale:8080"  # Points directly to Headscale container
    
    headplane-direct-svc:
      loadBalancer:
        servers:
          - url: "http://headplane:3000"  # Points directly to Headplane container
          
    headplane-redirect-svc:
      loadBalancer:
        servers:
          - url: "http://headplane:3000"  # For root redirect to admin

  serversTransports:
    insecureTransport:
      insecureSkipVerify: true

This configuration:

  • Sets up middlewares for redirection, CORS, and authentication
  • Creates routers for Headscale API and Headplane UI
  • Implements a special root path redirect to the admin interface
  • Configures services to direct traffic to the appropriate containers

Part 5: Create Docker Compose File for Headscale

Create a new Docker Compose file specifically for Headscale and Headplane:

nano docker-compose.headscale.yml

Add the following content:

services:
  headscale:
    image: headscale/headscale:0.25.1
    container_name: headscale
    restart: unless-stopped
    command: serve
    volumes:
      - ./headscale/config:/etc/headscale
      - ./headscale/data:/var/lib/headscale
    networks:
      - pangolin
    # No Traefik labels needed - we're routing through main Traefik

  headplane:
    image: ghcr.io/tale/headplane:0.5.1
    container_name: headplane
    restart: unless-stopped
    volumes:
      - ./headplane/config.yaml:/etc/headplane/config.yaml
      - ./headscale/config/config.yaml:/etc/headscale/config.yaml
      - /var/run/docker.sock:/var/run/docker.sock:ro
    environment:
      ROOT_API_KEY: 'GENERATE_API_KEY' # Generate with: openssl rand -hex 16
      COOKIE_SECRET: 'SAME_AS_CONFIG_YAML' # Same as in config.yaml
      DISABLE_API_KEY_LOGIN: 'true'
    networks:
      - pangolin
    # No Traefik labels needed - we're routing through main Traefik

networks:
  pangolin:
    external: true  # Connect to existing Pangolin network

Generate a secure API key:

openssl rand -hex 16

Replace GENERATE_API_KEY with the generated value, and make sure SAME_AS_CONFIG_YAML matches the cookie secret in your Headplane config.yaml.

Part 6: DNS Configuration

Make sure you set up DNS records for:

  • heads.intranet.hhf.technology - Points to your server IP
  • *.ts.hhf.technology - Wildcard record for Tailscale node hostnames

Part 7: Launch the Services

Now it’s time to start everything:

# Start Headscale and Headplane
docker compose -f docker-compose.headscale.yml up -d

# Restart Pangolin and Traefik to apply configuration changes
docker compose down
docker compose up -d

Part 8: Generate Headscale API Key

After everything is running, generate an API key for Headscale:

docker compose -f docker-compose.headscale.yml exec headscale headscale apikeys create

Save this API key for future use with API interactions. You have to place it in compose file of headscale and this will be used to login also.

Part 9: Debugging and Troubleshooting

Here are some useful commands for debugging your setup:

Check if Containers are Running

docker ps | grep -E 'headscale|headplane|traefik|pangolin|gerbil'

View Container Logs

# Traefik logs
docker compose logs -f traefik

# Headscale logs
docker compose -f docker-compose.headscale.yml logs -f headscale

# Headplane logs
docker compose -f docker-compose.headscale.yml logs -f headplane

Check Network Connectivity

# List networks
docker network ls

# Inspect the pangolin network
docker network inspect pangolin

# Make sure all containers are connected to the right network
docker inspect -f '{{.NetworkSettings.Networks}}' headscale
docker inspect -f '{{.NetworkSettings.Networks}}' headplane

Test Connectivity Between Containers

# Test if headplane can reach headscale
docker compose -f docker-compose.headscale.yml exec headplane wget -O- http://headscale:8080/health

# Test if Traefik can reach headscale
docker compose exec traefik wget -O- http://headscale:8080/health

Check DNS Resolution

# Test DNS resolution for your domains
dig heads.intranet.hhf.technology
dig test.ts.hhf.technology

Verify Certificate Issues

# Check Traefik's acme.json file
docker compose exec traefik cat /letsencrypt/acme.json

Part 10: Using Headscale with Tailscale Clients

Now that your Headscale server is running, you can start connecting clients:

Create Your First Namespace

docker compose -f docker-compose.headscale.yml exec headscale headscale namespaces create home

Generate a Pre-Auth Key

docker compose -f docker-compose.headscale.yml exec headscale headscale --user home preauthkeys create --expiration 24h

This will output a pre-auth key that you can use to connect clients.

Connect a Client

On a client machine, install the official Tailscale client and run:

tailscale up --login-server=https://heads.intranet.hhf.technology --authkey=YOUR_AUTH_KEY

Replace YOUR_AUTH_KEY with the pre-auth key you generated.

Use the Headplane Web UI

Access the Headplane admin interface at:

https://heads.intranet.hhf.technology/admin

From here, you can:

  • Manage nodes and namespaces
  • View connection status
  • Add or remove routes
  • Generate pre-auth keys
  • Configure ACLs

Common Issues and Solutions

404 Error on Root Domain

If you get a 404 error on the root domain but /admin works, check your root router configuration:

headscale-root-rtr:
  rule: "Host(`heads.intranet.hhf.technology`) && Path(`/`)"
  service: noop@internal  # Critical: use noop@internal, not a backend service
  middlewares:
    - headscale-redirect
  priority: 100

Certificate Issues with Wildcard Domains

For the Tailscale subdomain wildcard certificate, you may need to use DNS challenge instead of HTTP challenge. Update your Traefik configuration accordingly.

Containers Can’t Communicate

If containers can’t communicate with each other, make sure they’re on the same Docker network:

# List networks each container is connected to
docker inspect -f '{{range $key, $value := .NetworkSettings.Networks}}{{$key}} {{end}}' headscale
docker inspect -f '{{range $key, $value := .NetworkSettings.Networks}}{{$key}} {{end}}' traefik

If needed, connect a container to the network:

docker network connect pangolin headscale

Debugging Redirect Issues

To debug redirect issues, check the response headers:

curl -I https://heads.intranet.hhf.technology

The response should include a Location header with the redirect URL.

Conclusion

You now have a complete Headscale + Headplane setup integrated with your existing Pangolin installation. This powerful combination gives you:

  • An identity-aware reverse proxy with Pangolin
  • A self-hosted Tailscale control server with Headscale
  • A user-friendly UI for managing your Tailscale network with Headplane
  • All services accessible through a single Traefik instance

This integrated setup provides a comprehensive solution for secure network access, identity management, and service exposure in your self-hosted environment.

6 Likes

Wow, you’re amazing. I still have on my to-do list to start to aggregate the paths of common software’s for Pangolin, and now you’ve added something else to my to-do list :slight_smile:

Thanks again!

~Spritz

2 Likes

This is next level! It’s incredible how quickly and accurately you write and provide guides. Thank you very much. I’d like to use the internal DERP server in Headscale and use the VPS as an exit node at the same time. Would it be possible for you to extend your guide accordingly?"

1 Like

I will not get a chance to implement this for few days but thanks a lot @hhf.technoloy for this amazing work.

After reading this, I feel biggest pain would be merging the traefik dynamic file with all the new headscale/headplane stuff with previous crowsec etc.

Any tips on doing that without breaking everything? :laughing:

1 Like

i am afraid that a whole new setup. but fairly similar. headscale/derp-example.yaml at main · juanfont/headscale
you can add routes and middleware need and your derp server will be up and running.
I will make a seperate from this doc because it will confuse the mass readers. :slight_smile:

i didn’t use crowdsec for a reason, because managing it will be complex with headscale.
But you can separately deploy it. this is example with running 2 traefik instance together.
you will have to manage certs by copying acme.json in headscale-traefik instance.
I hope this all makes sense. i currently use this setup.

services:
  headscale:
    image: headscale/headscale:0.25.1
    container_name: headscale
    restart: unless-stopped
    command: serve
    volumes:
      - ./headscale/config:/etc/headscale
      - ./headscale/data:/var/lib/headscale
    networks:
      - headscale-network
      - pangolin  # Join the Pangolin network
    # No Traefik labels needed - we're routing through main Traefik

  headplane:
    image: ghcr.io/tale/headplane:0.5.1
    container_name: headplane
    restart: unless-stopped
    volumes:
      - ./headplane/config.yaml:/etc/headplane/config.yaml
      - ./headscale/config/config.yaml:/etc/headscale/config.yaml
      - /var/run/docker.sock:/var/run/docker.sock:ro
    environment:
      ROOT_API_KEY: 'ZToFW4i.LOsroTyiXRkFPGBMXqErV4d0mr9MpbH3'
      COOKIE_SECRET: 'Y9cyfQWAaXGcAKMPFk35ivoaCOtpKdfT'
      DISABLE_API_KEY_LOGIN: 'true'
    networks:
      - headscale-network
      - pangolin  # Join the Pangolin network
    # No Traefik labels needed - we're routing through main Traefik

  # This can be optional if using main Traefik
  # You can keep it for isolated development and testing
  headscale-traefik:
    image: traefik:v3.3.4
    restart: unless-stopped
    container_name: headscale-traefik
    command:
      - --api.insecure=true
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --providers.docker.network=headscale-network
      - --entrypoints.web.address=:80
      - --entrypoints.websecure.address=:443
      - --certificatesresolvers.letsencrypt.acme.tlschallenge=true
      - --certificatesresolvers.letsencrypt.acme.email=discourse@hhf.technology
      - --certificatesresolvers.letsencrypt.acme.storage=/certificates/acme.json
      - --global.sendAnonymousUsage=false
    ports:
      - 8081:80     # For HTTP traffic
      - 8443:443    # For HTTPS traffic 
      - 8082:8080   # For Traefik dashboard
    networks:
      - headscale-network
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik/certificates:/certificates
      - ./traefik/auth/users.htpasswd:/auth/users.htpasswd
    labels:
      - traefik.enable=true
      - traefik.http.middlewares.auth.basicauth.usersfile=/auth/users.htpasswd
      - traefik.http.middlewares.cors.headers.accesscontrolallowmethods=GET,OPTIONS,PUT,POST
      - traefik.http.middlewares.cors.headers.accesscontrolallowheaders=*
      - traefik.http.middlewares.cors.headers.accesscontrolalloworiginlist=https://heads.headscale.hhf.technology
      - traefik.http.middlewares.cors.headers.accesscontrolmaxage=100
      - traefik.http.middlewares.cors.headers.addvaryheader=true
      - traefik.http.middlewares.rewrite-admin.addprefix.prefix=/admin

networks:
  headscale-network:
    driver: bridge
  pangolin:
    external: true  # Connect to existing Pangolin network
name: pangolin
networks:
  default:
    driver: bridge
    name: pangolin
services:
  crowdsec:
    command: -t
    container_name: crowdsec
    environment:
      ACQUIRE_FILES: /var/log/traefik/*.log
      COLLECTIONS: crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules
      ENROLL_INSTANCE_NAME: pangolin-crowdsec
      ENROLL_TAGS: docker
      GID: "1000"
      PARSERS: crowdsecurity/whitelists
    expose:
      - 6060
    healthcheck:
      test:
        - CMD
        - cscli
        - capi
        - status
    image: crowdsecurity/crowdsec:v1.6.6-rc5
    labels:
      - traefik.enable=false
    ports:
      - 6060:6060
    restart: unless-stopped
    volumes:
      - ./config/crowdsec:/etc/crowdsec
      - ./config/crowdsec/db:/var/lib/crowdsec/data
      - ./config/crowdsec_logs/auth.log:/var/log/auth.log:ro
      - ./config/crowdsec_logs/syslog:/var/log/syslog:ro
      - ./config/crowdsec_logs:/var/log
      - ./config/traefik/logs:/var/log/traefik
  gerbil:
    cap_add:
      - NET_ADMIN
      - SYS_MODULE
    command:
      - --reachableAt=http://gerbil:3003
      - --generateAndSaveKeyTo=/var/config/key
      - --remoteConfig=http://pangolin:3001/api/v1/gerbil/get-config
      - --reportBandwidthTo=http://pangolin:3001/api/v1/gerbil/receive-bandwidth
    container_name: gerbil
    depends_on:
      pangolin:
        condition: service_healthy
    image: fosrl/gerbil:1.0.0
    ports:
      - 51820:51820/udp
      - 443:443
      - 80:80
    restart: unless-stopped
    volumes:
      - ./config/:/var/config
  pangolin:
    container_name: pangolin
    healthcheck:
      interval: 3s
      retries: 5
      test:
        - CMD
        - curl
        - -f
        - http://localhost:3001/api/v1/
      timeout: 3s
    image: fosrl/pangolin:1.0.1
    restart: unless-stopped
    volumes:
      - ./config:/app/config
  traefik:
    command:
      - --configFile=/etc/traefik/traefik_config.yml
    container_name: traefik
    depends_on:
      pangolin:
        condition: service_healthy
    image: traefik:v3.3.3
    network_mode: service:gerbil
    restart: unless-stopped
    volumes:
      - ./config/traefik:/etc/traefik:ro
      - ./config/letsencrypt:/letsencrypt
      - ./config/traefik/logs:/var/log/traefik
      - ./config/traefik/rules:/rules # New addition


################################################################
# API and Dashboard
################################################################

api:
  insecure: true
  dashboard: true

################################################################
# Providers - https://doc.traefik.io/traefik/providers/docker/
################################################################

providers:
  http:
    endpoint: "http://pangolin:3001/api/v1/traefik-config"
    pollInterval: "5s"
  file:
    directory: /rules
    watch: true

################################################################
# Plugins- Badger, Bouncer traefik  (crowdsec), souin
################################################################

experimental:
  plugins:
    badger:
      moduleName: "github.com/fosrl/badger"
      version: "v1.0.0"
    crowdsec:
      moduleName: "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
      version: "v1.3.5"
    # http cache for traefik
    souin:
      moduleName: github.com/darkweak/souin
      version: v1.6.50

################################################################
# Logs - https://doc.traefik.io/traefik/observability/logs/
################################################################

log:
  level: "INFO"
  format: "json"

################################################################
# Access logs - https://doc.traefik.io/traefik/observability/access-logs/
################################################################

accessLog:
  filePath: "/var/log/traefik/access.log"
  format: json
  filters:
    statusCodes:
      - "200-299"  # Success codes
      - "400-499"  # Client errors
      - "500-599"  # Server errors
    retryAttempts: true
    minDuration: "100ms"  # Increased to focus on slower requests
  bufferingSize: 100      # Add buffering for better performance
  fields:
    defaultMode: drop     # Start with dropping all fields
    names:
      ClientAddr: keep
      ClientHost: keep
      RequestMethod: keep
      RequestPath: keep
      RequestProtocol: keep
      DownstreamStatus: keep
      DownstreamContentSize: keep
      Duration: keep
      ServiceName: keep
      StartUTC: keep
      TLSVersion: keep
      TLSCipher: keep
      RetryAttempts: keep
    headers:
      defaultMode: drop
      names:
        User-Agent: keep
        X-Real-Ip: keep
        X-Forwarded-For: keep
        X-Forwarded-Proto: keep
        Content-Type: keep
        Authorization: redact  # Redact sensitive information
        Cookie: redact        # Redact sensitive information

################################################################
# Let's Encrypt (ACME)
################################################################

certificatesResolvers:
  letsencrypt:
    acme:
      httpChallenge:
        entryPoint: web
      email: "discourse@hhf.technology"
      storage: "/letsencrypt/acme.json"
      caServer: "https://acme-v02.api.letsencrypt.org/directory"
      #dnsChallenge:
        #provider: cloudflare
        #delayBeforeCheck: 30 # Default is 2m0s.  This changes the delay (in seconds)
        # Custom DNS server resolution
        #resolvers:
          #- "1.1.1.1:53"
          #- "8.8.8.8:53"


################################################################
# Entrypoints - https://doc.traefik.io/traefik/routing/entrypoints/
################################################################

entryPoints:
  web:
    address: ":80"
    forwardedHeaders:
      trustedIPs:
        # Cloudflare (https://www.cloudflare.com/ips-v4)
        - "173.245.48.0/20"
        - "103.21.244.0/22"
        - "103.22.200.0/22"
        - "103.31.4.0/22"
        - "141.101.64.0/18"
        - "108.162.192.0/18"
        - "190.93.240.0/20"
        - "188.114.96.0/20"
        - "197.234.240.0/22"
        - "198.41.128.0/17"
        - "162.158.0.0/15"
        - "104.16.0.0/13"
        - "104.24.0.0/14"
        - "172.64.0.0/13"
        - "131.0.72.0/22"
        # Local IPs
        - "127.0.0.1/32"
        - "10.0.0.0/8"
        - "192.168.0.0/16"
        - "172.16.0.0/12"  
  websecure:
    address: ":443"
    transport:
      respondingTimeouts:
        readTimeout: "30m"
    http:
      tls:
        options: tls-opts@file
        certResolver: letsencrypt
    http3: {}
    forwardedHeaders:
      trustedIPs:
        # Cloudflare (https://www.cloudflare.com/ips-v4)
        - "173.245.48.0/20"
        - "103.21.244.0/22"
        - "103.22.200.0/22"
        - "103.31.4.0/22"
        - "141.101.64.0/18"
        - "108.162.192.0/18"
        - "190.93.240.0/20"
        - "188.114.96.0/20"
        - "197.234.240.0/22"
        - "198.41.128.0/17"
        - "162.158.0.0/15"
        - "104.16.0.0/13"
        - "104.24.0.0/14"
        - "172.64.0.0/13"
        - "131.0.72.0/22"
        # Local IPs
        - "127.0.0.1/32"
        - "10.0.0.0/8"
        - "192.168.0.0/16"
        - "172.16.0.0/12"
serversTransport:
  insecureSkipVerify: true
http:
  middlewares:
    crowdsec:
      plugin:
        crowdsec:
          clientTrustedIPs:
            - 10.0.0.0/8
            - 172.16.0.0/12
            - 192.168.0.0/16
            - 100.89.137.0/20
          crowdsecAppsecEnabled: true
          crowdsecAppsecFailureBlock: true
          crowdsecAppsecHost: crowdsec:7422
          crowdsecAppsecUnreachableBlock: true
          crowdsecLapiHost: crowdsec:8080
          crowdsecLapiKey: 82veNXhNok2cDycbjQUnHeQDQQiKqTNLfmFm29ljr34
          crowdsecLapiScheme: http
          crowdsecMode: live
          defaultDecisionSeconds: 15
          enabled: true
          forwardedHeadersTrustedIPs:
            - 0.0.0.0/0
          httpTimeoutSeconds: 10
          logLevel: INFO
          updateIntervalSeconds: 15
          updateMaxFailure: 0
http:
  middlewares:
    default-headers:
      headers:
        frameDeny: true
        browserXssFilter: true
        contentTypeNosniff: true
        forceSTSHeader: true
        stsIncludeSubdomains: true
        stsPreload: true
        stsSeconds: 15552000
        customFrameOptionsValue: SAMEORIGIN
        customRequestHeaders:
          X-Forwarded-Proto: https
################################################################
# Dynamic configuration
################################################################
http:
  middlewares:
    middlewares-compress:
      compress:
        includedContentTypes:
          - application/json
          - text/html
          - text/plain
        minResponseBodyBytes: 1024
        defaultEncoding: gzip 
http:
  middlewares:
    # Middleware to rewrite root path to /admin for Headplane
    headscale-rewrite-admin:
      addPrefix:
        prefix: "/admin"
    
    # CORS middleware for Headscale API
    cors:
      headers:
        accessControlAllowMethods:
          - GET
          - OPTIONS
          - PUT
          - POST
        accessControlAllowHeaders:
          - "*"
        accessControlAllowOriginList:
          - "https://heads.headscale.hhf.technology"
        accessControlMaxAge: 100
        addVaryHeader: true
################################################################
# Dynamic configuration
################################################################
http:
  middlewares:
    # Middleware for Redirection
    # This can be used instead of global redirection
    middlewares-https-redirectscheme:
      redirectScheme:
        scheme: https
        permanent: true 
################################################################
# Dynamic configuration
################################################################
http:
  middlewares:
    # DDoS Prevention
    middlewares-rate-limit:
      rateLimit:
        average: 100
        burst: 50
################################################################
# Dynamic configuration
################################################################
http:
  middlewares:
    ################################################################
    # Good Basic Security Practices
    ################################################################
    middlewares-secure-headers:
      headers:
        accessControlAllowMethods:
          - GET
          - OPTIONS
          - PUT
        accessControlMaxAge: 100
        hostsProxyHeaders:
          - "X-Forwarded-Host"
        stsSeconds: 63072000
        stsIncludeSubdomains: true
        stsPreload: true
        forceSTSHeader: true
        # customFrameOptionsValue: "allow-from https:"DOMAINNAME" #CSP takes care of this but may be needed for organizr.
        customFrameOptionsValue: SAMEORIGIN # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options
        contentTypeNosniff: true
        browserXssFilter: true
        sslForceHost: true # add sslHost to all of the services
        sslHost: "*.testing.hhf.technology"
        referrerPolicy: "same-origin"
        permissionsPolicy: "camera=(), microphone=(), geolocation=(), payment=(), usb=()"
        customResponseHeaders:
          X-Robots-Tag: "none,noarchive,nosnippet,notranslate,noimageindex,noindex,nofollow" #global not tracking with websearch
          # X-Robots-Tag: "noindex,nofollow" "  # nextcloud recommandation 
          server: ""
          # https://community.traefik.io/t/how-to-make-websockets-work-with-traefik-2-0-setting-up-rancher/1732
          # X-Forwarded-Proto: "https"
http:
  # if you want add authentik on your service just add this on your router
  routers:
    # HTTP to HTTPS redirect router
    main-app-router-redirect:
      rule: "Host(`pangolin.crowdsec.hhf.technology`)"
      service: next-service
      entryPoints:
        - web
      middlewares:
        - middlewares-https-redirectscheme

    # Next.js router (handles everything except API and WebSocket paths)
    next-router:
      rule: "Host(`pangolin.crowdsec.hhf.technology`) && !PathPrefix(`/api/v1`)"
      service: next-service
      entryPoints:
        - websecure
      tls:
        certResolver: letsencrypt

    # API router (handles /api/v1 paths)
    api-router:
      rule: "Host(`pangolin.crowdsec.hhf.technology`) && PathPrefix(`/api/v1`)"
      service: api-service
      entryPoints:
        - websecure
      tls:
        certResolver: letsencrypt

    # WebSocket router
    ws-router:
      rule: "Host(`pangolin.crowdsec.hhf.technology`)"
      service: api-service
      entryPoints:
        - websecure
      tls:
        certResolver: letsencrypt

    # Headscale main API router - for all non-admin paths
    headscale-api-rtr:
      rule: "Host(`heads.headscale.hhf.technology`) && !PathPrefix(`/admin`)"
      service: headscale-direct-svc
      entryPoints:
        - websecure
      tls:
        certResolver: letsencrypt
      middlewares:
        - cors@file

    # Headplane admin interface router
    headplane-admin-rtr:
      rule: "Host(`heads.headscale.hhf.technology`) && PathPrefix(`/admin`)"
      service: headplane-direct-svc
      entryPoints:
        - websecure
      tls:
        certResolver: letsencrypt

    # Root path redirect to admin for better UX
    headscale-root-rtr:
      rule: "Host(`heads.headscale.hhf.technology`) && Path(`/`)"
      service: headplane-redirect-svc
      entryPoints:
        - websecure
      tls:
        certResolver: letsencrypt
      middlewares:
        - headscale-rewrite-admin@file

    # Tailscale subdomain handling - for nodes connecting to control server
    headscale-ts-rtr:
      rule: "HostRegexp(`{subdomain:[a-zA-Z0-9-]+}.ts.hhf.technology`)"
      service: headscale-direct-svc
      entryPoints:
        - websecure
      tls:
        certResolver: letsencrypt

    traefik-rtr:
      rule: "Host(`traefik.crowdsec.hhf.technology`)"
      service: api@internal
      entryPoints:
        - websecure
      tls:
        certResolver: letsencrypt
      middlewares:
        - traefik-dashboard-auth@file

  services:
    next-service:
      loadBalancer:
        servers:
          - url: "http://pangolin:3002"  # Next.js server

    api-service:
      loadBalancer:
        servers:
          - url: "http://pangolin:3000"  # API/WebSocket server

    # Direct connection to Headscale container
    headscale-direct-svc:
      loadBalancer:
        servers:
          - url: "http://headscale:8080"  # Points directly to Headscale container

    # Direct connection to Headplane container
    headplane-direct-svc:
      loadBalancer:
        servers:
          - url: "http://headplane:3000"  # Points directly to Headplane container
          
    # For the root path redirect to admin
    headplane-redirect-svc:
      loadBalancer:
        servers:
          - url: "http://headplane:3000"  # For root redirect to admin

  serversTransports:
    insecureTransport:
      insecureSkipVerify: true
tls:
  options:
    tls-opts:
      minVersion: VersionTLS12
      sniStrict: true
      cipherSuites:
        - TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
        - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
        - TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
        - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
        - TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305
        - TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305
        - TLS_AES_128_GCM_SHA256
        - TLS_AES_256_GCM_SHA384
        - TLS_CHACHA20_POLY1305_SHA256
http:
  middlewares:
    traefik-dashboard-auth:
      basicAuth:
        users:
          # This is a hashed username:password combination for "admin:password"
          # You should generate your own with htpasswd: htpasswd -nb admin YOUR_PASSWORD
          - "admin:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/"
1 Like

is there any guide for just Tailscale directly?

2 Likes

What are you looking to do? If you just want tailscale, there is a lot of documentation on tailscale website and their YouTube channel is also really good

1 Like

Hi everyone, everything is working perfectly with these wonderful guides. It’s a bit of a hassle because you can change it manually before running the command. But does anyone know how to change the URL or the actual URL of my instance somewhere in the .yml files? Like: https://hs.mydomain.com

Thanks.

1 Like

that is for tailscale directly. I am asking tailscale with pangolin. just as headscale setup here.

1 Like

you can use any connecting method with pangolin it all depends on your creativity and comfort.
not the recommended way to connect it. you can ping me on discord privately.

Hello i try to install headplane/headscale on my pangolin server.
I can’t reach the headplane admin site. I found this on my traefik logs.

traefik  | 2025-03-28T15:27:45Z ERR error="the service \"headscale-direct-svc@file\" does not exist" entryPointName=websecure routerName=headscale-api-rtr@file
traefik  | 2025-03-28T15:27:45Z ERR error="the service \"headplane-direct-svc@file\" does not exist" entryPointName=websecure routerName=headplane-admin-rtr@file
traefik  | 2025-03-28T15:27:45Z ERR error="the service \"headscale-direct-svc@file\" does not exist" entryPointName=websecure routerName=headscale-ts-rtr@file

you have middleware missing or indentation issue. please recheck against the main documentation.

i will solve this today. its currently not possible

Oh my fail, i found the typo.

1 Like

A clarification for the next visitor: in “Part 8: Generate Headscale API Key” it says “After everything is running, generate an API key for Headscale”:

docker compose -f docker-compose.headscale.yml exec headscale headscale apikeys create

This will create a 90-day API_KEY. If it expires, we’ll have to generate another one with:

docker exec headscale headscale apikeys create -e 9999d

In that case, we can use -e (expire-interval). 9999d is 27 years; that’s optional.

The important thing is that it says to save that API KEY to access the Headscale web portal. That’s correct. What’s incorrect is the text “You have to place it in the Headscale compose file.” You don’t have to put it in any Compose file.

Useful command to see the API key expiration date:

docker exec headscale headscale apikeys list

hhf.technoloy, thanks for these great guides!

1 Like

thank you for you rhelp to refine docs as always :slight_smile:

but then what will go here

image

your part of long-lived key is correct
but then what we will put here

Sorry, after so many tests…

Something curious: yesterday I generated a new AKI_KEY and entered it into Docker Compose, but it didn’t work; it only entered with the previously generated one.

I just did another installation to test it, and it’s true that I skipped Part 5. Generate a secure API key:

openssl rand -hex 16

Replace GENERATE_API_KEY with the generated value.

That key is generated with:

docker exec headscale headscale apikeys create -e 9999d

And it’s the one we enter in Docker Compose and for web authentication.

Thanks

1 Like

Hello i have updated the headplane Version to 0.5.7 but now the redirect to /admin/ didn’t work.
I found this issue on github:

With trailing slash behind /admin/ it works.

    at getInternalRouterError (file:///app/build/server/assets/index-Bvd9BWog.js:7333:5)
    at Object.query (file:///app/build/server/assets/index-Bvd9BWog.js:6312:19)
    at handleDocumentRequest (file:///app/build/server/assets/index-Bvd9BWog.js:10964:40)
    at requestHandler (file:///app/build/server/assets/index-Bvd9BWog.js:10879:24)
    at file:///app/build/server/assets/index-Bvd9BWog.js:11287:14
    at Array.<anonymous> (file:///app/build/server/assets/index-Bvd9BWog.js:11288:7)
    at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
    at async responseViaResponseObject (file:///app/build/server/assets/index-Bvd9BWog.js:2015:13)
<Router basename="/admin/"> is not able to match the URL "/admin" because it does not start with the basename, so the <Router> won't render anything.```
1 Like

Thanks will update it after a bit of testing

1 Like