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