Skip to main content

Secure Apache Guacamole with Pomerium

What this guide does

You'll put Apache Guacamole behind Pomerium so that Pomerium handles single sign-on and authorization, then forwards the authenticated user's email to Guacamole in an HTTP header. Guacamole's header authentication extension reads that header and signs the user in, so users don't get a second login prompt. Guacamole still seeds a local administrator account; rotate or delete it before exposing the service.

This guide secures access to the Guacamole gateway. It does not cover adding remote-desktop connections inside Guacamole.

When to use this guide

Use it when you want one front door for Guacamole with your existing identity and you want Pomerium, not Guacamole, to own authentication. Because Guacamole trusts the forwarded header, this pattern depends on Guacamole never being reachable except through Pomerium. See Security considerations before exposing it.

Prerequisites

This guide assumes you've completed the Quickstart, so you already have Pomerium running and signing users in through the hosted authenticate service.

You also need:

  • Docker and Docker Compose
  • A domain you control for the Guacamole route (this guide uses guacamole.yourdomain.com)

Configure Pomerium

In the Zero Console:

  1. Create a Route. In From, enter https://guacamole.<your-starter-domain>; in To, enter http://guacamole:8080.
  2. Set the policy to Any Authenticated User (or scope it to a group or domain).
  3. On the Headers tab, enable Pass Identity Headers, and add a JWT claim header mapping email to X-Pomerium-Claim-Email. That unsigned header is what Guacamole reads to identify the user.

Configure Guacamole

Guacamole runs as three services: the guacd daemon, a PostgreSQL database, and the web application. The web application turns on header authentication with two environment variables:

  • HEADER_ENABLED=true enables the header-auth extension.
  • HTTP_AUTH_HEADER=X-Pomerium-Claim-Email tells Guacamole which header carries the already-authenticated user.

When a request arrives with that header, Guacamole accepts the named user as authenticated. It does not validate a password, which is exactly why only Pomerium may reach it (see Security considerations).

Guacamole stores its users and connections in PostgreSQL, so the database needs Guacamole's schema loaded on first boot. Generate it once into an init/ directory next to your Compose file:

mkdir -p init
docker run --rm guacamole/guacamole /opt/guacamole/bin/initdb.sh --postgresql > init/initdb.sql

The Compose file below mounts that init/ directory into PostgreSQL so the schema loads automatically.

Run the stack

The Compose file runs Pomerium Core alongside Guacamole's three services (for Zero, drop the pomerium service and use the compose.yaml from the Quickstart with your POMERIUM_ZERO_TOKEN, keeping the rest below):

docker-compose.yaml
services:
# The guacd daemon renders remote desktop protocols (VNC, RDP, SSH) for the web app.
guacd:
image: guacamole/guacd@sha256:8974eaa9ba32f713daf311e7cc8cd7e4cdfba1edea39eed75524e78ef4b08f4f # 1.6.0
restart: always
networks: [guac-internal]

# PostgreSQL stores Guacamole's users, connections, and history. The schema is
# loaded once from ./init on first boot; generate it with:
# docker run --rm guacamole/guacamole /opt/guacamole/bin/initdb.sh --postgresql > init/initdb.sql
postgres:
image: postgres@sha256:df7bca0066e6f60cc3dd32faa70caddec20e2c22b58932f79498e5704b23854a # 15-alpine
environment:
POSTGRES_DB: guacamole_db
POSTGRES_USER: guacamole_user
POSTGRES_PASSWORD: ChooseYourOwnPasswordHere1234
volumes:
- ./init:/docker-entrypoint-initdb.d:ro
- guacamole-data:/var/lib/postgresql/data
restart: always
networks: [guac-internal]

# The Guacamole web application. HEADER_ENABLED turns on header authentication and
# HTTP_AUTH_HEADER tells it which header carries the already-authenticated user.
guacamole:
image: guacamole/guacamole@sha256:f344085e618bb05e22b964b0208dbd06d3468275bac70206f93805245e067b40 # 1.6.0
depends_on:
- guacd
- postgres
environment:
GUACD_HOSTNAME: guacd
POSTGRESQL_HOSTNAME: postgres
POSTGRESQL_DATABASE: guacamole_db
POSTGRESQL_USERNAME: guacamole_user
POSTGRESQL_PASSWORD: ChooseYourOwnPasswordHere1234
HEADER_ENABLED: 'true'
HTTP_AUTH_HEADER: X-Pomerium-Claim-Email
# Serve the app at / so the route host maps straight to it (no /guacamole prefix).
WEBAPP_CONTEXT: ROOT
restart: always
networks: [guac-internal]

pomerium:
image: pomerium/pomerium@sha256:e10d1d267af24f581157f485d9b0bc08469e2428675b696a08e42ceb09b2279c # v0.32.7
volumes:
- ./config.yaml:/pomerium/config.yaml:ro
- pomerium-cache:/data
ports:
- 443:443
- 80:80
restart: always
# Pomerium is the only service with published ports and the only one bridging to
# the internal network, so Guacamole is reachable only through Pomerium.
networks: [default, guac-internal]

networks:
# No host access: keeps guacd/postgres/guacamole off the host and reachable only by
# pomerium, which the header-auth trust model requires.
guac-internal:
internal: true

volumes:
guacamole-data:
pomerium-cache:

Start it:

docker compose up -d

On the first run, PostgreSQL loads Guacamole's schema before the web app can connect, so the guacamole container may restart a few times until the database is ready. restart: always handles this; give it up to a minute to settle.

Verify the setup

  1. The route requires authentication. In a fresh browser, open https://guacamole.yourdomain.com. You should be redirected to sign in, not straight into Guacamole.

  2. An allowed user gets in. Sign in. Pomerium redirects you back to Guacamole.

  3. Header auth signs you in. You land on the Guacamole home screen as your own user, not the Guacamole username/password form.

    Guacamole home screen, signed in via the forwarded Pomerium identity

    To confirm it's the header (not a form login) that authenticated you, use the signed-in browser session to mint a token through Guacamole's REST API. Open your browser's developer console on the Guacamole page and run:

    await fetch('/api/tokens', {method: 'POST'}).then((r) => r.json());

    The response should include your forwarded email as username and header as dataSource:

    {"authToken": "...", "username": "you@example.com", "dataSource": "header"}
  4. A disallowed user is blocked. Sign in as a user your policy excludes and open https://guacamole.yourdomain.com. Pomerium denies access, so no identity header is forwarded and you never reach Guacamole.

Common failure modes

  • Guacamole shows its own login form instead of signing you in. The header didn't arrive. Confirm the route has pass_identity_headers: true and a jwt_claims_headers mapping for X-Pomerium-Claim-Email, and that HTTP_AUTH_HEADER matches it exactly.
  • Unexpected internal error / password is an empty string in the Guacamole logs. The web app can't reach PostgreSQL. Check the POSTGRESQL_HOSTNAME, POSTGRESQL_USERNAME, and POSTGRESQL_PASSWORD values on the guacamole service match the database, and that init/initdb.sql was generated and mounted.
  • Redirect loop or certificate errors. Make sure DNS for guacamole.yourdomain.com points at Pomerium and that Pomerium can obtain a TLS certificate. On the Core path, autocert needs ports 80 and 443 reachable for Let's Encrypt; Zero manages certificates for you.

Security considerations

The header-auth model is only as safe as two properties the Compose file above enforces, plus two steps you own in production:

  • Pomerium owns the identity header. With HEADER_ENABLED=true, Guacamole signs in whoever the X-Pomerium-Claim-Email header names, with no password. The header is unsigned, so its trustworthiness depends on Pomerium setting it. Pomerium overwrites any inbound copy of the header with the authenticated user before the request reaches Guacamole, so a client that forges X-Pomerium-Claim-Email through Pomerium is still signed in as its real identity, never the spoofed one.

  • Guacamole is reachable only through Pomerium. The forged-header protection only matters if a client can't skip Pomerium and talk to Guacamole directly. The Compose file puts guacd, postgres, and guacamole on an internal: true network and publishes host ports only on pomerium, so nothing but Pomerium can route to guacamole:8080. If you deploy differently, keep Guacamole off public ports and off any network a client can reach. A client that could reach Guacamole directly would forge the header and bypass authentication entirely.

  • Rotate or delete the default guacadmin account. Guacamole's database schema seeds a built-in administrator, guacadmin / guacadmin, that authenticates against the database, not the header. Header auth doesn't remove it: a client that reaches Guacamole's form login directly (off Pomerium) could sign in with it and get full admin access. Through Pomerium the forwarded header takes over before the form login runs, so remediate it in the database. Delete the row or give it a strong, unique password:

    docker compose exec postgres psql -U guacamole_user -d guacamole_db \
    -c "DELETE FROM guacamole_entity WHERE name = 'guacadmin' AND type = 'USER';"
  • Scope the route policy to the group or domain that should have access. Header auth grants a Guacamole session to every user your Pomerium policy allows.

Operations

Guacamole's users, connections, and history live in PostgreSQL on the guacamole-data volume, so they persist across restarts.

docker compose down # stop the stack, keep the database
docker compose down -v # stop and delete the volumes (resets the DB, including guacadmin)

Next steps