Apache Guacamole is a clientless remote desktop gateway: users open a web page and get SSH, RDP or VNC sessions to your servers without installing any client. In this tutorial you will deploy Guacamole on Ubuntu 24.04 with Docker Compose, using PostgreSQL to store users and connections, publish it over HTTPS behind Nginx, add SSH and RDP connections and require TOTP two-factor authentication for every login.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS with at least 2 GB of RAM, for example a CubePath VPS, and a non-root user with sudo privileges.
  • Docker Engine and the Docker Compose plugin installed.
  • A domain name, your_domain in this guide, with an A record pointing to the server's public IP address. It is needed for the Let's Encrypt certificate.
  • Network access from this server to the machines you want to reach (TCP 22 for SSH, 3389 for RDP, 5900 and up for VNC).

Step 1 - Understanding the components

A Guacamole deployment has three parts, each running in its own container:

ContainerRole
guacdProxy daemon that speaks SSH, RDP and VNC to the target machines
guacamoleJava web application that serves the HTML5 client and talks to guacd
postgresDatabase that stores users, groups, connections and history

Only the web application needs to be reachable, and only through Nginx. guacd and PostgreSQL stay on the internal Docker network.

Step 2 - Preparing the project directory

Create a directory for the deployment:

sudo mkdir -p /opt/guacamole/initdb
sudo chown -R "$USER": /opt/guacamole
cd /opt/guacamole

The Guacamole image ships a script that prints the database schema. Use it to generate the SQL that PostgreSQL will run on its first start. Pinning the image version keeps the schema and the web application in sync:

docker run --rm guacamole/guacamole:1.6.0 /opt/guacamole/bin/initdb.sh --postgresql > initdb/01-schema.sql

Check that the file contains the schema:

grep -c 'CREATE TABLE' initdb/01-schema.sql

The command prints a number above 20. The same script also creates the default administrator account guacadmin, which you will replace in Step 5.

Generate a random database password and store it in a .env file that Docker Compose reads automatically:

echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" > .env
chmod 600 .env

Step 3 - Writing the Compose file

Create the Compose file:

nano /opt/guacamole/compose.yaml
services:
  guacd:
    image: guacamole/guacd:1.6.0
    restart: unless-stopped

  postgres:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_DB: guacamole_db
      POSTGRES_USER: guacamole_user
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - ./initdb:/docker-entrypoint-initdb.d:ro
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U guacamole_user -d guacamole_db"]
      interval: 10s
      timeout: 5s
      retries: 5

  guacamole:
    image: guacamole/guacamole:1.6.0
    restart: unless-stopped
    depends_on:
      guacd:
        condition: service_started
      postgres:
        condition: service_healthy
    environment:
      GUACD_HOSTNAME: guacd
      POSTGRESQL_HOSTNAME: postgres
      POSTGRESQL_DATABASE: guacamole_db
      POSTGRESQL_USERNAME: guacamole_user
      POSTGRESQL_PASSWORD: ${POSTGRES_PASSWORD}
      REMOTE_IP_VALVE_ENABLED: "true"
    ports:
      - "127.0.0.1:8080:8080"

volumes:
  pgdata:

A few details matter here:

  • The web application is published on 127.0.0.1 only. Docker writes its own iptables rules and bypasses UFW, so publishing on 0.0.0.0 would expose plain HTTP to the internet even with a firewall enabled.
  • REMOTE_IP_VALVE_ENABLED makes Guacamole trust the X-Forwarded-For header from the local proxy, so logs and connection history show the real client IP instead of Nginx's.
  • The SQL files in initdb run only when the pgdata volume is empty, that is, on the very first start.

Start the stack:

docker compose up -d

Check that all three containers are running and PostgreSQL is healthy:

docker compose ps
NAME                     IMAGE                       SERVICE     STATUS
guacamole-guacamole-1    guacamole/guacamole:1.6.0   guacamole   Up 30 seconds   127.0.0.1:8080->8080/tcp
guacamole-guacd-1        guacamole/guacd:1.6.0       guacd       Up 41 seconds
guacamole-postgres-1     postgres:16                 postgres    Up 41 seconds (healthy)

Confirm that the web application answers locally:

curl -sI http://127.0.0.1:8080/guacamole/ | head -n 1
HTTP/1.1 200

If you get a connection error, give Tomcat a few more seconds to start and check docker compose logs guacamole.

Step 4 - Publishing Guacamole over HTTPS with Nginx

Install Nginx and Certbot with its Nginx plugin:

sudo apt update
sudo apt install nginx certbot python3-certbot-nginx

Open SSH and web traffic in UFW and enable the firewall if it is not active yet:

sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

Create a server block for Guacamole:

sudo nano /etc/nginx/sites-available/guacamole
server {
    listen 80;
    listen [::]:80;
    server_name your_domain;

    location = / {
        return 302 /guacamole/;
    }

    location /guacamole/ {
        proxy_pass http://127.0.0.1:8080/guacamole/;
        proxy_buffering off;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        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;
        proxy_read_timeout 3600s;
        access_log off;
    }
}

Guacamole streams the remote session over a WebSocket, which is why the Upgrade and Connection headers are passed through and buffering is disabled. The long proxy_read_timeout keeps idle sessions from being cut after a minute.

Enable the site, test the configuration and reload Nginx:

sudo ln -s /etc/nginx/sites-available/guacamole /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Request a certificate. Certbot edits the server block to listen on 443 and adds the HTTP to HTTPS redirect:

sudo certbot --nginx -d your_domain

Certbot also installs a systemd timer that renews the certificate automatically. Check it with:

sudo certbot renew --dry-run

Open https://your_domain/ in a browser. You are redirected to the Guacamole login page.

Step 5 - Replacing the default administrator

The schema created the account guacadmin with the password guacadmin. Everyone knows it, so replace it before doing anything else:

  1. Log in as guacadmin / guacadmin.
  2. Open the user menu in the top right corner and select Settings, then the Users tab, and click New User.
  3. Enter a username and a strong password, and under Permissions tick every administrative permission (Administer system, Create new users, Create new user groups, Create new connections, Create new connection groups, Create new sharing profiles). Save.
  4. Log out, log in with the new account, go back to Settings > Users, open guacadmin and click Delete.

Verify by trying to log in as guacadmin again: the login must fail.

Step 6 - Adding an SSH connection

Connections are defined in Settings > Connections > New Connection. For an SSH connection to a Linux server, fill in:

  • Name: a descriptive name, for example web-01 SSH.
  • Protocol: SSH.
  • Network > Hostname and Port: the target's IP address and 22.
  • Authentication > Username: the account on the target.
  • Authentication > Private key: the private key in OpenSSH PEM format, if you use key authentication. Leave the password empty so Guacamole never stores it.

To create a dedicated key for Guacamole, run this on your workstation, not on the Guacamole server:

ssh-keygen -t ed25519 -f ~/.ssh/guacamole_web01 -C guacamole
ssh-copy-id -i ~/.ssh/guacamole_web01.pub your_user@target_ip

Paste the contents of ~/.ssh/guacamole_web01 into the Private key field and save. The key is stored in the Guacamole database, so treat database backups as sensitive.

Go back to the home screen and click the new connection. A terminal opens in the browser tab. Press Ctrl+Alt+Shift to open the Guacamole menu for clipboard, file transfer and disconnect.

Step 7 - Adding an RDP connection

For a Windows machine or a Linux server running xrdp, create another connection with:

  • Protocol: RDP.
  • Network > Hostname and Port: the target's IP address and 3389.
  • Authentication > Username, Password and, for Active Directory accounts, Domain. If you leave the password empty, Guacamole asks for it when the connection starts.
  • Authentication > Security mode: NLA for current Windows versions. Use Any if the connection fails and you are not sure what the server supports.
  • Authentication > Ignore server certificate: tick it only if the target uses a self-signed certificate, which is the Windows default.

Save and open the connection from the home screen. If the target is not reachable, test the port from the Guacamole server, which is where guacd opens the connection from:

nc -zv target_ip 3389
Connection to target_ip 3389 port [tcp/ms-wbt-server] succeeded!

Step 8 - Requiring TOTP two-factor authentication

A gateway that opens shells on your servers should never rely on a password alone. The Guacamole image includes the TOTP extension, which you enable with an environment variable. Add these lines to the environment section of the guacamole service in compose.yaml:

      TOTP_ENABLED: "true"
      TOTP_ISSUER: "Guacamole your_domain"

Recreate the container so it picks up the new variables:

docker compose up -d

Log out and log in again. After the password, Guacamole shows a QR code. Scan it with an authenticator app, enter the six-digit code and you are in. From now on, every user must enter a code at each login, and users who have not enrolled yet are asked to do so on their next login.

Step 9 - Backing up and updating

All state lives in PostgreSQL, so a database dump is a full backup of users and connections:

cd /opt/guacamole
docker compose exec -T postgres pg_dump -U guacamole_user guacamole_db | gzip > guacamole-$(date +%F).sql.gz

To update, read the release notes first, because some versions ship database schema upgrade scripts that you must apply manually. Then change the three 1.6.0 image tags and run:

docker compose pull
docker compose up -d

Troubleshooting

The login page shows "An error has occurred and this action cannot be completed": the web application cannot reach the database. Check docker compose logs guacamole for PostgreSQL authentication errors and make sure the password in .env has not changed since the volume was created.

Login works but connections fail immediately: check docker compose logs guacd. Messages like Unable to connect or Connection timed out mean the target is unreachable from the server or blocked by a firewall on the target.

Sessions freeze or disconnect after about 60 seconds: the WebSocket upgrade is not reaching Guacamole. Confirm that the Nginx location includes the Upgrade and Connection headers and proxy_read_timeout, then reload Nginx.

RDP fails with a security negotiation error: change Security mode to Any or NLA and enable Ignore server certificate for self-signed targets.

Conclusion

You now have Apache Guacamole running with PostgreSQL behind Nginx and HTTPS, with the default account removed, SSH and RDP connections configured and TOTP required for every user. As next steps, create user groups so that each team only sees its own connections, restrict access to the gateway with firewall rules or a VPN where possible, and schedule the database dump from Step 9 with a systemd timer.