Skip to content

Apache Guacamole

Docs


Overview

Apache Guacamole is a clientless remote desktop gateway: RDP, VNC and SSH sessions in a plain browser tab, with nothing installed on the machine you're sitting at. The browser talks HTML5/WebSocket to the Guacamole web app; the web app hands the session to guacd, which speaks the actual RDP/VNC/SSH protocol to the target.

Three containers:

Container Role
guacamole Tomcat web app: login, connection list, the HTML5 client
guacd Proxy daemon that connects to the targets (RDP via FreeRDP)
guacamole-db Postgres: users, connections, permissions, TOTP enrollments, history

A browser-reachable remote desktop is a high-value target, so treat it like one: put it behind SSO and enforce TOTP, and expose nothing else.

Security layers (outside in)

  1. SSO at the edge (Cloudflare Access, Authelia, Authentik, ...): no request reaches Guacamole without an SSO login first.
  2. Guacamole's own login (database auth), with TOTP mandatory for every user, enrolled at first login.
  3. Brute-force ban (bundled auth-ban extension): 5 bad logins ban the client IP for 15 minutes. The real client IP comes from X-Forwarded-For, trusted only from the reverse proxy's network.
  4. No host ports. The reverse proxy reaches guacamole:8080 over a shared Docker network; guacd and Postgres are only on private stack networks, and the DB network has no route out.
  5. Targets defend themselves too: a desktop's RDP port should only accept connections from the Guacamole host, not the whole LAN.

Compose

compose.yaml
name: guacamole

services:
  guacamole:
    image: guacamole/guacamole:1.6.0@sha256:f344085e618bb05e22b964b0208dbd06d3468275bac70206f93805245e067b40
    container_name: guacamole
    restart: unless-stopped
    environment:
      # 1.6.0: every guacamole.properties key can be set as an env var (upper-case, '-' -> '_')
      GUACD_HOSTNAME: guacd
      GUACD_PORT: "4822"
      POSTGRESQL_ENABLED: "true"
      POSTGRESQL_HOSTNAME: guacamole-db
      POSTGRESQL_DATABASE: guacamole_db
      POSTGRESQL_USERNAME: guacamole_user
      POSTGRESQL_PASSWORD: ${POSTGRES_PASSWORD}
      # Users must exist in the DB (no auto-provisioning) and need a strong password
      POSTGRESQL_AUTO_CREATE_ACCOUNTS: "false"
      POSTGRESQL_USER_PASSWORD_MIN_LENGTH: "14"
      POSTGRESQL_USER_PASSWORD_PROHIBIT_REUSE: "5"
      # TOTP enforced for every user, enrolled on first login
      TOTP_ENABLED: "true"
      TOTP_ISSUER: "Guacamole"
      # Brute-force protection (auth-ban, bundled and on by default; explicit here)
      BAN_ENABLED: "true"
      BAN_MAX_INVALID_ATTEMPTS: "5"
      BAN_ADDRESS_DURATION: "900"
      # Behind a reverse proxy: take the client IP from X-Forwarded-For, but only from the proxy network.
      # Regex for the proxy network's subnet (docker network inspect tunnel-net).
      REMOTE_IP_VALVE_ENABLED: "true"
      REMOTE_IP_VALVE_INTERNAL_PROXIES: '172\.24\.\d{1,3}\.\d{1,3}'
      # Serve at / instead of /guacamole/
      WEBAPP_CONTEXT: ROOT
      LOG_LEVEL: info
      TZ: <TZ>
    depends_on:
      guacd:
        condition: service_healthy
      guacamole-db:
        condition: service_healthy
    networks:
      - tunnel-net      # reverse proxy -> guacamole:8080
      - guac-backend    # -> guacd
      - guac-db         # -> postgres
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    mem_limit: 1g
    # curl is in the image (Tomcat base)
    healthcheck:
      test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://localhost:8080/"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 60s

  guacd:
    # Built locally (./guacd/Dockerfile, see below). `pull_policy: build` = never pulled; rebuilt on deploy.
    # If the official image works with your targets, use `image: guacamole/guacd:1.6.0` instead.
    build:
      context: ./guacd
    image: local/guacd:1.6.0-trixie
    pull_policy: build
    container_name: guacd
    restart: unless-stopped
    environment:
      LOG_LEVEL: info
    read_only: true
    tmpfs:
      - /tmp:size=64m
      - /home/guacd:size=16m,uid=999,gid=999   # FreeRDP writes ~/.config/freerdp at connect time
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    mem_limit: 1g
    cpus: '4.0'   # decoding H.264 + re-encoding the browser stream is the CPU-heavy part
    # Not `internal: true`: guacd has to reach the LAN targets
    networks:
      - guac-backend
    healthcheck:
      test: ["CMD-SHELL", "nc -z localhost 4822 || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 10s

  guacamole-db:
    image: postgres:17.11-alpine@sha256:1bcc18d81671b02906296aad470ec9778a6b71016ce953092024b680bad007b0
    container_name: guacamole-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: guacamole_db
      POSTGRES_USER: guacamole_user
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      TZ: <TZ>
    volumes:
      - /mnt/appdata/guacamole/postgres:/var/lib/postgresql/data
      - /mnt/appdata/guacamole/initdb:/docker-entrypoint-initdb.d:ro
    networks:
      - guac-db
    security_opt:
      - no-new-privileges:true
    mem_limit: 512m
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U guacamole_user -d guacamole_db"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 20s

networks:
  tunnel-net:
    external: true      # shared with the reverse proxy
  guac-backend:
  guac-db:
    internal: true      # Postgres has no route out
.env.example
POSTGRES_PASSWORD=<YOUR_PASSWORD>   # openssl rand -hex 32

Pinned images

Images are pinned to tag@sha256:digest; Renovate keeps both up to date (including the Debian base in the Dockerfile). Postgres is on the manual-review list like every database.

First-time database init

The official image doesn't create the schema itself. Generate it once, before the first start, into the directory Postgres runs on an empty data dir:

sudo install -d -m 755 /mnt/appdata/guacamole/initdb
sudo docker run --rm guacamole/guacamole:1.6.0 /opt/guacamole/bin/initdb.sh --postgresql \
  | sudo tee /mnt/appdata/guacamole/initdb/001-schema.sql >/dev/null
docker compose up -d

Then log in as guacadmin / guacadmin, enroll TOTP, create your real admin user (with all admin permissions), log in as that user, enroll its TOTP, and delete guacadmin.

Adding an RDP connection

Settings > Connections > New Connection, protocol RDP:

  • Hostname / port: the desktop's address and 3389.
  • Authentication: the desktop user's credentials (or leave them empty to be prompted).
  • Security mode: Any or NLA; tick Ignore server certificate if the desktop uses a self-signed cert.
  • Display: resize method "Display update" lets the remote desktop follow the browser window size.

Firewall the desktop's RDP port so only the Guacamole host can reach it.

Why a custom guacd build

The official guacamole/guacd:1.6.0 image ships FreeRDP 2 built without FFmpeg, so it can't decode H.264 (AVC420). Some newer RDP servers, notably Wayland desktop RDP servers, only send H.264 and drop any client that can't take it ("Client does not support H.264 ..."). If your target works with the official image, use that and skip this section.

The Dockerfile builds the same guacd 1.6.0 source on debian:trixie-slim against Debian's FreeRDP 3 (built with FFmpeg), plus a one-line patch so guacd actually advertises H.264 when the graphics pipeline is on:

guacd/Dockerfile
ARG DEBIAN_IMAGE=debian:trixie-slim@sha256:62bd423aeee912639ca8ee681c2b762cb3e25137e0a92b20be455898a9f034ad

FROM ${DEBIAN_IMAGE} AS build
ARG GUAC_VERSION=1.6.0
ARG GUAC_SHA256=8bc45675da96d7b6f39728160181e3d4ff3c08f460f6d26de5805b642bf13f2b
RUN apt-get update && apt-get install -y --no-install-recommends \
        build-essential ca-certificates curl pkg-config autoconf automake libtool-bin \
        freerdp3-dev libcairo2-dev libjpeg62-turbo-dev libpng-dev libwebp-dev uuid-dev \
        libpango1.0-dev libssh2-1-dev libtelnet-dev libvncserver-dev libwebsockets-dev \
        libpulse-dev libssl-dev libvorbis-dev \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /src
RUN curl -fsSLo guac.tgz "https://archive.apache.org/dist/guacamole/${GUAC_VERSION}/source/guacamole-server-${GUAC_VERSION}.tar.gz" \
    && echo "${GUAC_SHA256}  guac.tgz" | sha256sum -c - \
    && tar xzf guac.tgz --strip-components=1
# Also advertise H.264 (AVC420) when the Graphics Pipeline is on. guacd 1.6.0 never sets FreeRDP_GfxH264 and
# FreeRDP 3 defaults it to off. The grep fails the build if upstream moves the line.
RUN sed -i 's|^\(\s*\)freerdp_settings_set_bool(rdp_settings, FreeRDP_SupportGraphicsPipeline, TRUE);|&\n\1freerdp_settings_set_bool(rdp_settings, FreeRDP_GfxH264, TRUE);|' \
        src/protocols/rdp/settings.c \
    && grep -q 'FreeRDP_GfxH264, TRUE' src/protocols/rdp/settings.c
# guacd's FreeRDP plugins must sit in FreeRDP's own addin dir. FreeRDP >= 3.6 deprecates codecs_new/free,
# which is a warning, not a break, but guacd builds with -Werror.
RUN CFLAGS="-O2 -Wno-error=deprecated-declarations" ./configure --prefix=/opt/guacamole --disable-guacenc --disable-guaclog --disable-kubernetes \
        --with-freerdp-plugin-dir=/usr/lib/x86_64-linux-gnu/freerdp3 \
    && make -j"$(nproc)" && make install
# Runtime package list = the packages owning every shared library guacd and its plugins link
RUN find /opt/guacamole /usr/lib/x86_64-linux-gnu/freerdp3 -name '*.so*' -o -path '*/sbin/guacd' \
      | xargs ldd 2>/dev/null | awk '/=> \//{print $3}' | grep -v '^/opt/' | xargs -r readlink -f | sort -u \
      | xargs -r dpkg -S 2>/dev/null | cut -d: -f1 | sort -u > /opt/guacamole/DEPENDENCIES \
    && mkdir -p /opt/freerdp-plugins && cp -a /usr/lib/x86_64-linux-gnu/freerdp3/*guac* /opt/freerdp-plugins/

FROM ${DEBIAN_IMAGE}
COPY --from=build /opt/guacamole /opt/guacamole
RUN apt-get update \
    && xargs apt-get install -y --no-install-recommends netcat-openbsd fonts-dejavu-core ca-certificates \
         < /opt/guacamole/DEPENDENCIES \
    && rm -rf /var/lib/apt/lists/* \
    && useradd --system --create-home --home-dir /home/guacd --shell /usr/sbin/nologin guacd
COPY --from=build /opt/freerdp-plugins/ /usr/lib/x86_64-linux-gnu/freerdp3/
ENV LD_LIBRARY_PATH=/opt/guacamole/lib LC_ALL=C.UTF-8
USER guacd
EXPOSE 4822
CMD ["/opt/guacamole/sbin/guacd", "-b", "0.0.0.0", "-L", "info", "-f"]

To update: bump GUAC_VERSION + GUAC_SHA256 (from the .sha256 file next to the source tarball on archive.apache.org) and the Debian base digest. Debian security updates for FreeRDP only land when the image is rebuilt, so rebuild regularly: docker compose build --pull guacd && docker compose up -d guacd.

Backups

Back up /mnt/appdata/guacamole, but exclude the live Postgres data directory and take a pg_dump instead (a file copy of a running database can be torn). The initdb directory is tiny and worth keeping with it.

Sources & further reading