Skip to content

Self-host Gul

Run the Gul server from the pre-built image:

  • GitHub Container Registry - ghcr.io/pianonic/gul:latest

You need a Linux/Windows host with Docker + Compose v2, and an existing reverse proxy that already terminates TLS for a wildcard domain. Gul does not do TLS or DNS itself - it reads the Host header, matches a subdomain, and forwards. The proxy and the certificates are yours.

Before you start

Gul assumes three things already exist:

You needWhy
A base domain, e.g. gul.example.comTunnels are handed out as <name>.gul.example.com.
Wildcard DNS: *.gul.example.com and gul.example.com -> your hostSo every tunnel resolves to the machine running your proxy.
A reverse proxy with a wildcard TLS cert for *.gul.example.comGul speaks plain HTTP on 8080; the proxy terminates TLS and forwards both the wildcard and the apex to it.
An OIDC provider with a public (PKCE) clientOnly the CLI control connection authenticates. See OIDC setup.

TIP

Because every tunnel is a new subdomain, per-host certificates don't scale. Use a wildcard certificate (Caddy's on-demand TLS, or a DNS-01 ACME challenge in nginx/Traefik).

Quickstart

Drop these two files in an empty folder and run docker compose up -d. The Gul server has no state to persist - the tunnel registry lives in memory - so there are no volumes.

compose.yml

yaml
# Gul sits behind an EXISTING wildcard reverse proxy. That proxy terminates TLS for
# *.gul.example.com AND the apex gul.example.com and forwards both to this container on
# port 8080. Point your proxy at `gul:8080` (same docker network) or publish the port.
services:
  gul:
    image: ghcr.io/pianonic/gul:latest
    container_name: gul
    restart: unless-stopped
    environment:
      Gul__BaseDomain: ${GUL_BASE_DOMAIN}          # e.g. gul.example.com
      Oidc__Authority: ${GUL_OIDC_AUTHORITY}       # your OIDC issuer
      Oidc__ClientId: ${GUL_OIDC_CLIENT_ID}        # public (PKCE) client id
      Oidc__Scopes: "openid profile email"
      Oidc__RequireHttpsMetadata: "true"
    # If your reverse proxy shares this docker network it reaches the container as `gul:8080`
    # and you need no `ports:` mapping. Publish the port only if the proxy runs on the host
    # or another machine:
    # ports:
    #   - "8080:8080"

.env

env
# The apex domain Gul hands out subdomains under. Must equal the wildcard your proxy terminates.
GUL_BASE_DOMAIN=gul.example.com

# Your OIDC provider. Only the CLI control connection authenticates against it - tunnel
# visitors are anonymous.
GUL_OIDC_AUTHORITY=https://auth.example.com
GUL_OIDC_CLIENT_ID=gul

That's the whole server. The next two sections wire up the proxy and the OIDC client.

Reverse proxy

Forward both the wildcard and the apex to the container, preserving the original Host header (Gul reads it to pick the subdomain).

Caddy - the whole config is two lines. Caddy fetches a wildcard cert on demand:

caddy
*.gul.example.com, gul.example.com {
    reverse_proxy gul:8080
}

nginx - one server block with a wildcard server_name and a wildcard certificate (issued out of band via DNS-01). Pass the host through:

nginx
server {
    listen 443 ssl;
    server_name .gul.example.com;               # matches the apex and every subdomain
    ssl_certificate     /etc/ssl/gul/fullchain.pem;   # wildcard cert for *.gul.example.com
    ssl_certificate_key /etc/ssl/gul/privkey.pem;
    location / {
        proxy_pass http://gul:8080;
        proxy_set_header Host $host;            # Gul needs the original host
    }
}

Traefik - route HostRegexp(`^.+\.gul\.example\.com$`) || Host(`gul.example.com`) to the gul service on port 8080, with a wildcard certresolver (DNS challenge). Traefik forwards the Host header by default.

WARNING

Whatever proxy you use, it must pass the untouched Host header. If Gul sees the proxy's own hostname instead of happy-otter.gul.example.com, it can't find the tunnel and serves the apex control plane instead.

OIDC provider setup

The CLI logs in with Authorization Code + PKCE on a loopback redirect (http://127.0.0.1:<port>/). Register Gul on your IdP as a public client (no secret):

SettingValue
Client typePublic (PKCE, no client secret)
Allowed redirect URIshttp://127.0.0.1/* and http://localhost/*
Scopesopenid profile email

The CLI binds an ephemeral loopback port at login time, so the redirect port isn't fixed - allow the whole 127.0.0.1/localhost host with a wildcard path. The server never receives the code; it only validates the resulting access token on the hub.

  • Pocket ID - toggle Public Client; add both loopback redirect URIs.
  • Authentik - use the Provider's issuer (/application/o/<slug>/) as Oidc__Authority.
  • Auth0 - authority is https://<tenant>.auth0.com/ (trailing slash); app type Native.
  • Keycloak - authority is https://<host>/realms/<realm>; set the client to public and add the redirect URIs.

Configuration reference

Environment variables

Set these on the gul service (the Quickstart pulls them from .env). __ maps to nested config.

VariableWhat it does
Gul__BaseDomainThe apex domain tunnels live under, e.g. gul.example.com. Subdomains are handed out as <name>.gul.example.com. Must match the wildcard your reverse proxy terminates TLS for.
Oidc__AuthorityOIDC issuer / discovery URL. Gul validates control-connection tokens against <authority>/.well-known/openid-configuration. Must match the token's issuer byte-for-byte.
Oidc__ClientIdThe public (PKCE) client id the CLI logs in with. Also served to the CLI from GET /config.
Oidc__ScopesSpace-separated scopes requested at login. Default openid profile email.
Oidc__RequireHttpsMetadatatrue (default). Set false only for a plain-HTTP IdP in development.

The audience is not validated (ValidateAudience=false) - Gul only needs to know the token was minted by your IdP for a real user, not that it names a specific API.

What the server exposes

All served on port 8080, on the apex host (gul.example.com) - subdomains are always tunnel traffic:

PathAuthPurpose
GET /healthanonymousLiveness check - returns 200 OK.
GET /configanonymous{ authority, clientId, scopes, baseDomain } - the CLI reads this to bootstrap login.
/tunnelOIDC requiredThe SignalR control hub the CLI connects to.
/scalar/v1, /openapi/v1.jsonanonymousAPI reference - Development only.

Operations

Upgrade

bash
docker compose pull gul && docker compose up -d gul

There's no database and no migrations - the container comes up, clients reconnect, and each re-registers its subdomain. Pin a version by replacing :latest with a published tag.

Scale note. Gul's registry is in-memory and per-process, so run one replica. A dropped tunnel just means the CLI reconnects; there is no shared state to coordinate.


Troubleshooting

Common errors & fixes
SymptomFix
Apex/404 page instead of your appThe proxy isn't forwarding the wildcard host. Ensure *.gul.example.com and gul.example.com both proxy to gul:8080 and pass the original Host header.
No active tunnel for <sub> (502)No CLI is registered for that subdomain right now - run gul <port> again. The tunnel closes the moment the CLI exits.
Visitor request hangs, then 504The forwarded request timed out (~100s) - the local app is slow or the port the CLI targets isn't answering.
redirect_uri mismatch at loginThe OIDC client must allow http://127.0.0.1/* and http://localhost/*, and be a public (PKCE, no secret) client.
401 on the hub / "can't open a tunnel"Oidc__Authority must match the token's issuer byte-for-byte, and the discovery URL must be reachable from inside the container.
TLS error on a brand-new subdomainThe certificate must cover *.gul.example.com. Per-subdomain certs won't keep up - use a wildcard cert (Caddy on-demand TLS or a DNS-01 challenge).
Login opens no browser on a headless hostThe CLI prints the authorize URL as a fallback. The redirect returns to a loopback listener on the same machine, so complete login in a browser there (or tunnel the loopback port to your workstation).

See also: CLI client · Developer setup

Made with care by PianoNic.