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
sudoprivileges. - Docker Engine and the Docker Compose plugin installed.
- A domain name,
your_domainin 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:
| Container | Role |
|---|---|
guacd | Proxy daemon that speaks SSH, RDP and VNC to the target machines |
guacamole | Java web application that serves the HTML5 client and talks to guacd |
postgres | Database 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.1only. Docker writes its own iptables rules and bypasses UFW, so publishing on0.0.0.0would expose plain HTTP to the internet even with a firewall enabled. REMOTE_IP_VALVE_ENABLEDmakes Guacamole trust theX-Forwarded-Forheader from the local proxy, so logs and connection history show the real client IP instead of Nginx's.- The SQL files in
initdbrun only when thepgdatavolume 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:
- Log in as
guacadmin/guacadmin. - Open the user menu in the top right corner and select Settings, then the Users tab, and click New User.
- 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.
- Log out, log in with the new account, go back to Settings > Users, open
guacadminand 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:
NLAfor current Windows versions. UseAnyif 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.
TipIf a user loses their phone, an administrator can open the user in Settings > Users and clear the TOTP enrollment so they can enroll again.
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.
