Use case · Remote access gateway

Apache Guacamole self-hosted: a browser gateway for RDP, SSH and VNC

Self-hosting Apache Guacamole means running two pieces on a machine you control: guacd, the native proxy that speaks RDP, SSH and VNC, and a Java web application that serves the HTML5 client and handles logins. This page builds release 1.6.0 from the official Docker images with a PostgreSQL account store, keeps the default guacadmin login off the internet until it is replaced, adds TOTP, and puts nginx with WebSocket in front.

See the machines

How the gateway is put together

The browser loads a JavaScript client from the web application and talks to it over HTTP, preferring a WebSocket tunnel and falling back to plain HTTP when WebSocket is unavailable. The web application understands no remote desktop protocol at all. It forwards the Guacamole protocol to guacd, which loads a client plugin for RDP, VNC, SSH, telnet or Kubernetes and opens the session to the target machine. Users install nothing; the project calls this clientless.

ContainerRoleKeeps state?
guacamole/guacdNative proxy on port 4822; translates RDP, VNC and SSH into the Guacamole protocolNo (writes recordings if enabled)
guacamole/guacamoleWeb application on Tomcat 9, port 8080; authentication and admin UINo
postgresUsers, groups, connections, history, TOTP keysYes: the only container that must survive upgrades

guacd performs no authentication of its own. The manual warns that a reachable guacd can be used as a jumping point into other systems, so port 4822 stays on the Docker network and is never published.

What it needs from a machine

The manual publishes no CPU or memory figures, so size from what does the work. guacd decodes each remote display and re-encodes it for the browser; the 1.6.0 release notes describe a rewritten server-side optimizer that puts the host's CPU to better use, for quicker screens and less bandwidth per session. Tomcat holds the web tier in a JVM, the account database stays small, and graphical recordings grow with every recorded hour. Our reading: cores track concurrent graphical sessions, disk tracks recording retention, and memory needs headroom for the JVM plus PostgreSQL.

On the dedicated server range, a Moonshot cartridge is built for one service per box, which suits a gateway that should share its machine with nothing else. A racked single-socket server adds a full out-of-band console (HPE iLO, Dell iDRAC or a Supermicro BMC), which matters on the machine that holds the keys to everything behind it: if a firewall rule locks you out, the console still reaches it. The m510 and m710x cartridges carry one as well; the rest of the cartridge range offers power control alone. Every port is unmetered, and a racked machine is handed over in about 30 minutes once payment is approved.

Install with the database schema and a loopback port

Docker writes its own iptables rules for published ports, so ufw does not filter them on IPv4. An unqualified mapping such as 8080:8080 binds every IPv4 address and, on a host with IPv6, every IPv6 address as well; there it slips past ufw as well whenever the daemon runs with IPv6 networking enabled. The fence here is the 127.0.0.1 binding in the Compose file. Turn ufw on first, with IPv6 set before any allow rule so the rules cover both families. Docker Engine plus the Compose v2 plugin must already be present; then, as your own sudo user:

sudo sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
sudo ufw default deny incoming
sudo ufw allow OpenSSH
sudo ufw --force enable
sudo mkdir -p /opt/guacamole/initdb /opt/guacamole/recordings
cd /opt/guacamole
sudo docker run --rm guacamole/guacamole:1.6.0 /opt/guacamole/bin/initdb.sh --postgresql | sudo tee initdb/initdb.sql >/dev/null
sudo chmod 644 initdb/initdb.sql
sudo chown 1001:1000 recordings && sudo chmod 2750 recordings
sudo sh -c 'umask 077; p=$(openssl rand -hex 24); printf "POSTGRES_PASSWORD=%s\nPOSTGRESQL_PASSWORD=%s\n" "$p" "$p" > db.env'
sudo chmod 600 db.env

Guacamole does not create its own tables. The image's initdb.sh prints the PostgreSQL schema, and the postgres image runs any .sql file in /docker-entrypoint-initdb.d the first time it starts on an empty volume. The manual's Compose method shares the schema through a named initdb volume; writing the script's output to a file and bind-mounting it, as here, is our variant and does the same job. The password lives only in db.env at mode 600, never on a command line. The recordings directory follows the manual: guacd (UID 1001) writes, the web application (GID 1000) reads. Save this as /opt/guacamole/compose.yml:

services:
  guacd:
    image: guacamole/guacd:1.6.0
    restart: unless-stopped
    volumes:
      - ./recordings:/var/lib/guacamole/recordings
  postgres:
    image: postgres:16
    restart: unless-stopped
    env_file: db.env
    environment:
      POSTGRES_DB: guacamole_db
      POSTGRES_USER: guacamole_user
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./initdb:/docker-entrypoint-initdb.d:ro
  guacamole:
    image: guacamole/guacamole:1.6.0
    restart: unless-stopped
    depends_on: [guacd, postgres]
    env_file: db.env
    environment:
      GUACD_HOSTNAME: guacd
      POSTGRESQL_HOSTNAME: postgres
      POSTGRESQL_DATABASE: guacamole_db
      POSTGRESQL_USERNAME: guacamole_user
      TOTP_ENABLED: "true"
      RECORDING_ENABLED: "true"
      REMOTE_IP_VALVE_ENABLED: "true"
    volumes:
      - ./recordings:/var/lib/guacamole/recordings:ro
    ports:
      - "127.0.0.1:8080:8080"
volumes:
  pgdata:

From /opt/guacamole, start it with sudo docker compose up -d, then sudo ss -tlnp | grep 8080 should show 127.0.0.1 only. If the login page does not appear, sudo docker compose logs guacamole names the bad setting or the missing schema.

Replace guacadmin before anyone else can reach it

The schema creates one account, guacadmin with the password guacadmin. The manual says to change that password immediately. Our advice goes further: make the first login through an SSH tunnel, so the page answers your workstation alone, and delete the account once a named admin works.

  1. From your workstation: ssh -L 8080:127.0.0.1:8080 you@your-server.
  2. Open http://localhost:8080/guacamole/ and sign in as guacadmin. Because TOTP is enabled, you are asked to enrol an authenticator app at once.
  3. Under Settings, create a named user for yourself with every system permission, and set a long password.
  4. Log back in under the new name and enrol its TOTP code. Then delete guacadmin (our recommendation), or at minimum give it a new long password as the manual requires.

TOTP keys are stored through the database extension, which is why it comes first. Codes default to 6 digits, 30 seconds and SHA-1, the values Google Authenticator expects. From 1.6.0 an administrator can switch TOTP off per user or per group, and "Clear TOTP secret" re-enrols someone who lost a phone. A Duo extension exists too, now on Duo's v4 SDK. If users sign in through authentik or Keycloak via the OpenID Connect or SAML extension, let the identity provider enforce the second factor instead.

nginx in front, with WebSocket

The manual recommends a TLS-terminating reverse proxy for production. nginx needs Upgrade and Connection set explicitly for WebSocket, and proxy_buffering off, without which the HTTP tunnel stalls. With a certificate already issued for your name:

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name rd.example.com;
    ssl_certificate     /etc/letsencrypt/live/rd.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/rd.example.com/privkey.pem;

    location /guacamole/ {
        proxy_pass http://127.0.0.1:8080;
        proxy_buffering off;
        proxy_http_version 1.1;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $http_connection;
        access_log off;
    }
}

Then sudo ufw allow 80,443/tcp. nginx's default upload limit is 1 MB, so add client_max_body_size to the location if users transfer files. REMOTE_IP_VALVE_ENABLED makes Tomcat read the forwarded address; by default it trusts those headers only from private addresses (the manual's description of the default). Our reading is that nginx on the host reaches the container through Docker's bridge from a private address, so the default covers it. If client addresses still show as the proxy, set PROXY_ALLOWED_IPS_REGEX to the bridge gateway address. Without the valve every user appears as the proxy, and the brute-force extension, which the Docker image enables by default, bans after 5 failures for 300 seconds per address. Clients who already sit on a WireGuard VPN can reach Guacamole over the tunnel and skip the public listener.

RDP and SSH connection parameters

ParameterProtocolWhat it does
hostname, portRDP, SSHTarget address; RDP defaults to 3389, SSH to 22
securityRDPany (default) negotiates; nla needs credentials up front or prompts; rdp is legacy
ignore-cert / cert-tofuRDPAccept a self-signed certificate, or trust it on first use and check it later
username, passwordRDP, SSHOptional. SSH prompts when omitted; RDP prompts only under NLA, otherwise the Windows login screen appears
domainRDPOptional Windows domain for the login
private-key, passphraseSSHKey in OpenSSH format; passphrase prompted if absent
host-keySSHA known_hosts line; without it, the host identity is not verified

Set host-key on every SSH connection. For a Windows RDP server, the gateway lets you close 3389 to the internet and allow it from the gateway's address only.

Recording sessions

Any connection can be recorded as a Guacamole protocol dump. With RECORDING_ENABLED set, the recording storage extension plays them in the browser from the connection history. The manual's recommended setup is a recording path of ${HISTORY_PATH}/${HISTORY_UUID} with "automatically create path" ticked. SSH sessions can also write a text typescript with typescript-path. recording-include-keys captures keystrokes, which will include any password typed inside the session; turn it on only where policy demands it.

Backups and upgrades

Everything that matters is the database plus the recordings folder. As root, install -d -m 700 /root/guac-backups creates the target, after which cd /opt/guacamole && docker compose exec -T postgres pg_dump -U guacamole_user guacamole_db > /root/guac-backups/guac-$(date +%F).sql takes a dump; in a crontab escape each % as \%. Copy dumps and recordings off the machine yourself.

Upgrade sequence: release notes and a fresh dump come first. Schema changes ship as upgrade-pre-VERSION.sql files under postgresql/schema/upgrade/ in that release's guacamole-auth-jdbc archive; 1.6.0 adds upgrade-pre-1.6.0.sql for its AUDIT permission. With docker compose stop guacamole holding the web tier down, feed each relevant file to psql in version order, e.g. docker compose exec -T postgres psql -U guacamole_user -d guacamole_db -f - < upgrade-pre-1.6.0.sql. Last, bump both image tags to the same release, then docker compose pull followed by up -d. The new web application must never run against the old schema.

Licence and limits

Guacamole is Apache Software Foundation software under the Apache License 2.0: no licence fee and no user count. Commercial support exists from third parties; community support is the mailing lists. It is the wrong tool when the whole team already connects through a VPN with native clients, and a single gateway host takes every session down with it when it fails. The gateway also needs a network route to each target, so place it near the machines it serves.

Questions

What is the default Guacamole username and password?

With database authentication, the schema scripts create guacadmin with the password guacadmin. The manual says to change it as soon as the login works. We would do that over an SSH tunnel to the loopback port, before nginx or a DNS name exposes the login page.

Does Guacamole need a database?

Not strictly: LDAP and other extensions can authenticate users. A database is what stores connections, history and TOTP keys through the web UI, and the TOTP extension needs it for enrolment. The Docker image refuses to start without at least one authentication method configured.

Can I use MariaDB or MySQL instead of PostgreSQL?

Yes. Run initdb.sh --mysql instead and use the MYSQL_ variables (MYSQL_HOSTNAME, MYSQL_DATABASE, MYSQL_USERNAME, MYSQL_PASSWORD). The tables are the same in purpose; only the schema dialect differs.

Which port does Guacamole use?

The web application listens on 8080 inside its container and guacd on 4822. Publish only 8080, and only on 127.0.0.1 behind nginx on 443. Never publish 4822: guacd has no authentication.

Is Apache Guacamole secure enough to face the internet?

The manual's baseline is TLS at a reverse proxy plus brute-force protection, which the Docker image enables by default. Add TOTP for every account, keep RDP and SSH on the targets unreachable from outside, and upgrade when releases ship. The gateway can then be the only remote access path exposed.

Run the gateway on its own machine

Whole servers with root, an unmetered port and month-to-month terms, from a single-service cartridge to a two-socket box.

See the machines

Related on Server Room

Other use cases