All articles
TutorialsMay 04, 2026 · 20 min read

Self-Host Apache Guacamole on a VPS for Browser-Based RDP and SSH

Self-Host Apache Guacamole on a VPS for Browser-Based RDP and SSH

There's a class of moments where you really want to RDP into a server but the only thing you have at hand is a Chromebook, a locked-down work laptop, or a phone. Installing a native client isn't an option. That's exactly the gap Apache Guacamole fills. It's a clientless gateway that turns RDP, SSH, VNC, and Telnet into something you can use from any modern browser.

You drop it on a VPS, point your browser at guac.example.com, log in, and click a saved connection. The server does the protocol work and streams the session to your browser as HTML5. No client install, no native plugin, no port forwards from the device you're on - just a tab.

This tutorial walks through a production-ready install: Docker Compose, the official guacd and guacamole images, PostgreSQL for users and saved connections, and Caddy in front for automatic HTTPS.

The default credentials shipped with Guacamole are `guacadmin` / `guacadmin`. The very first thing you do after the first login is delete that account. Leaving it active is the single most common way self-hosted Guacamole instances get popped.

TL;DR

  • Spin up a VPS and install Docker
  • Point a subdomain like guac.example.com at the server
  • Run guacd, guacamole, and postgres with one compose file
  • Initialize the Postgres schema from the Guacamole image
  • Put Caddy in front for HTTPS
  • Log in, change the admin password, add your first connection
  • Restrict access with a VPN or IP allowlist

Total time: about 20 minutes.

What You Need

  • A VPS with at least 1 GB RAM (2 GB is more comfortable) running Ubuntu 22.04 or 24.04
  • A domain you can add DNS records to
  • Ports 80 and 443 open to the internet for Let's Encrypt
  • Network reachability from the VPS to whatever you want to RDP, SSH, or VNC into

The VPS does not need a desktop environment. It's a gateway: it talks RDP/SSH out, and HTTPS in.

How Guacamole Hangs Together

Three moving parts worth knowing about before you start:

  • guacd - the C daemon that speaks RDP, SSH, VNC, Telnet, and Kubernetes. It's the thing that opens the actual protocol session.
  • guacamole - a Java web application (the guacamole/guacamole image bundles Tomcat) that serves the browser UI and proxies traffic to guacd.
  • A database - Postgres or MySQL. Stores users, groups, saved connections, and connection history. The H2 in-memory default is fine for a five-minute demo and nothing else.

Browser talks HTTPS to Caddy, Caddy talks HTTP to the Guacamole web app, the web app talks the Guacamole protocol to guacd, and guacd talks RDP/SSH/VNC out to your target hosts.

Step 1: Point a Subdomain at the VPS

In your DNS provider, add an A record:

guac.example.com → YOUR_VPS_IPV4

Add an AAAA record for IPv6 if you have one. Verify:

dig +short guac.example.com

The output should match the VPS IP. DNS needs to resolve before Caddy can fetch a certificate.

Step 2: Install Docker and Docker Compose

On a fresh Ubuntu box:

sudo apt update sudo apt install -y ca-certificates curl sudo install -m 0755 -d /etc/apt/keyrings sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ -o /etc/apt/keyrings/docker.asc sudo chmod a+r /etc/apt/keyrings/docker.asc echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \ https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Confirm:

docker --version docker compose version

Step 3: Open the Firewall

If you use UFW:

sudo ufw allow 22/tcp sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw enable

Caddy needs 80 for the ACME HTTP challenge and 443 for HTTPS. Nothing else needs to be public - guacd and Postgres stay on the internal Docker network.

Step 4: Create the Project Directory

sudo mkdir -p /opt/guacamole cd /opt/guacamole sudo mkdir -p init postgres-data caddy-data caddy-config

The layout:

  • init/ - the Postgres schema we'll generate in the next step
  • postgres-data/ - the live database
  • caddy-data/ and caddy-config/ - Caddy's certificate and config state

Step 5: Generate the Postgres Schema

The Guacamole image ships a SQL script that creates every table the JDBC auth extension expects. You only need to run this once, before Postgres starts for the first time.

sudo docker run --rm guacamole/guacamole:1.5.5 \ /opt/guacamole/bin/initdb.sh --postgresql > initdb.sql sudo mv initdb.sql init/initdb.sql

Postgres's official image runs every .sql file under /docker-entrypoint-initdb.d/ on first start. Mounting init/ to that path is what we'll do in the compose file.

The schema only initializes when the Postgres data directory is empty. If you ever wipe `postgres-data/` you'll need this same `initdb.sql` to be in place, or first login will fail with `INVALID_CREDENTIALS` even with the right password.

Step 6: Set Strong Passwords

Generate two random passwords now and keep them to hand:

openssl rand -base64 24 # use this for POSTGRES_PASSWORD openssl rand -base64 24 # spare, for the Guacamole admin user later

Don't reuse the Postgres password for anything else. It only matters to the containers.

Step 7: Write the Compose File

Create /opt/guacamole/docker-compose.yml:

services: postgres: image: postgres:15 container_name: guac-postgres restart: unless-stopped environment: POSTGRES_DB: guacamole_db POSTGRES_USER: guacamole_user POSTGRES_PASSWORD: "REPLACE_WITH_POSTGRES_PASSWORD" volumes: - ./postgres-data:/var/lib/postgresql/data - ./init:/docker-entrypoint-initdb.d:ro networks: - guacnet guacd: image: guacamole/guacd:1.5.5 container_name: guacd restart: unless-stopped networks: - guacnet guacamole: image: guacamole/guacamole:1.5.5 container_name: guacamole restart: unless-stopped depends_on: - guacd - postgres environment: GUACD_HOSTNAME: guacd POSTGRES_HOSTNAME: postgres POSTGRES_DATABASE: guacamole_db POSTGRES_USER: guacamole_user POSTGRES_PASSWORD: "REPLACE_WITH_POSTGRES_PASSWORD" WEBAPP_CONTEXT: ROOT networks: - guacnet caddy: image: caddy:2 container_name: caddy restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - ./caddy-data:/data - ./caddy-config:/config networks: - guacnet networks: guacnet:

A few notes:

  • WEBAPP_CONTEXT: ROOT makes Guacamole serve at / instead of /guacamole/. Cleaner URLs and one less redirect.
  • Pin 1.5.5 (or whatever the current stable is when you read this). The latest tag breaks across major versions because the JDBC schema changes.
  • Don't expose 5432 or 8080 on the host. Caddy reaches the web app over the internal guacnet network.

Step 8: Write the Caddyfile

Create /opt/guacamole/Caddyfile:

guac.example.com { encode zstd gzip reverse_proxy guacamole:8080 { header_up X-Forwarded-Proto https } }

Caddy auto-renews Let's Encrypt certificates. No further config needed.

Step 9: Start the Stack

cd /opt/guacamole sudo docker compose up -d sudo docker compose logs -f

Watch the logs for two milestones:

  1. Postgres prints database system is ready to accept connections.
  2. Guacamole's Tomcat prints Server startup in [N] milliseconds.

Then open https://guac.example.com in a browser. You should see the Guacamole login page, with a valid HTTPS certificate.

Step 10: Replace the Default Admin

Sign in with guacadmin / guacadmin. Immediately:

  1. Click the username in the top right and choose Settings, then Users.
  2. Click New User, create a fresh admin account with the second strong password you generated. Tick all permissions under System and Connections.
  3. Log out, log back in as the new admin.
  4. Go back to Users, open guacadmin, and delete it.

Until you've done this, your gateway is one Shodan scan away from being someone's stepping stone.

Step 11: Add Your First Connection

A connection is the saved target for a session - hostname, port, protocol, optional username and password.

  1. SettingsConnectionsNew Connection.
  2. Pick a name like prod-rdp and protocol RDP.
  3. Under Parameters, set:
    • Hostname: the target's reachable address from the VPS
    • Port: 3389
    • Username and password (or leave blank to be prompted each time)
    • Security mode: NLA for modern Windows hosts
    • Ignore certificate: tick this if the target uses a self-signed cert
  4. Save.

Back on the home screen, the new connection appears under All Connections. Click it. The desktop opens in a new tab.

For SSH:

  • Protocol SSH, port 22, set the username
  • Either paste a private key under Authentication or use a password
  • Set Color scheme to gray-black if you prefer a dark terminal

For VNC: protocol VNC, port 5900, password if any.

Step 12: Restrict Access (Strongly Recommended)

A clientless RDP gateway on the open internet is a high-value target. Two clean ways to lock it down:

Behind a VPN. Install Tailscale on the VPS and on every device that needs access. Move Caddy off the public ports and only listen on the tailnet interface, or keep public listeners but add an IP allowlist for the tailnet range. See our Tailscale guide for the full setup.

IP allowlist with UFW. If you have static office and home IPs:

sudo ufw delete allow 80/tcp sudo ufw delete allow 443/tcp sudo ufw allow from 203.0.113.10 to any port 443 proto tcp sudo ufw allow from 198.51.100.20 to any port 443 proto tcp

You'll still need port 80 open temporarily for ACME renewals, or switch Caddy to the DNS challenge.

Enforce 2FA inside Guacamole. The guacamole-auth-totp extension adds time-based one-time passwords:

cd /opt/guacamole sudo mkdir -p extensions sudo curl -L -o extensions/guacamole-auth-totp-1.5.5.jar \ https://archive.apache.org/dist/guacamole/1.5.5/binary/guacamole-auth-totp-1.5.5.jar

Add a volume mount to the guacamole service:

volumes: - ./extensions:/etc/guacamole/extensions:ro

Restart the stack. Each user is prompted to enroll a TOTP secret on next login.

Step 13: Back Up Postgres

Everything that matters - users, groups, connections, history - lives in guacamole_db. The flat files do not.

Create /usr/local/bin/guacamole-backup.sh:

#!/usr/bin/env bash set -euo pipefail BACKUP_DIR="/var/backups/guacamole" DATE="$(date +%F)" mkdir -p "$BACKUP_DIR" docker exec guac-postgres pg_dump -U guacamole_user guacamole_db | \ gzip > "$BACKUP_DIR/guacamole-$DATE.sql.gz" find "$BACKUP_DIR" -name "guacamole-*.sql.gz" -mtime +14 -delete

Make it executable and schedule it nightly:

sudo chmod +x /usr/local/bin/guacamole-backup.sh echo "20 3 * * * root /usr/local/bin/guacamole-backup.sh" | \ sudo tee /etc/cron.d/guacamole-backup

Sync the backup directory off-box to S3 or Backblaze B2 on the same schedule for off-site safety.

Upgrading

cd /opt/guacamole sudo docker compose pull sudo docker compose up -d

Across minor versions this is fine. Across major versions (e.g. 1.5.x to 1.6.x) the JDBC schema may change. Always:

  1. Read the upgrade notes for that version on the Apache Guacamole site.
  2. Take a Postgres dump first.
  3. Apply the matching upgrade-pre-X.Y.Z.sql script from /opt/guacamole/postgresql/schema/upgrade/ inside the new image.

If you skipped step 1 and the web app refuses to start with a schema mismatch, restore the dump, downgrade the images, and try again following the notes.

Troubleshooting

Login page loads but guacadmin fails. The Postgres init script never ran, so the user table is empty. Stop the stack, wipe postgres-data/ (only if it's the first install), make sure init/initdb.sql exists, and bring it back up.

502 Bad Gateway from Caddy. The web app isn't listening yet, or WEBAPP_CONTEXT doesn't match what Caddy is hitting. Check docker compose logs guacamole for the Tomcat startup line.

Connection refused when launching an RDP session. guacd can't reach the target. Test from inside the container: docker exec -it guacd nc -vz TARGET_IP 3389.

Black screen after RDP login. Almost always the wrong Security mode. Modern Windows wants NLA; older Windows or Linux RDP servers want RDP or Any.

Browser shows valid TLS but the address bar warns about mixed content. WEBAPP_CONTEXT and Caddy's X-Forwarded-Proto header have to agree. The Caddyfile above already sends the header - if you customized it, make sure it's still there.

Going Further

  • Active Directory or LDAP login. The guacamole-auth-ldap extension binds Guacamole to your existing directory. Drop the JAR in extensions/, set the LDAP env vars, and remove local users.
  • Recording sessions. guacd can record every session as a .guac file. Useful for audit trails - mount a volume and turn on the recording-path parameter on each connection.
  • Quick connections without a database. If you just need a one-off gateway, skip Postgres entirely and use a static user-mapping.xml file. Less flexible, simpler.
  • Pair with a hardened Windows target. A self-hosted Guacamole gateway is only as safe as the boxes behind it. Walk your Windows hosts through our RDP hardening checklist before you expose them.

A clientless gateway on a small VPS gives you the convenience of a remote-desktop-as-a-service product without the per-seat fees, and without storing your sessions on someone else's servers.


Need a VPS to host a Guacamole gateway? Our Linux plans include fast IPv4, IPv6, and a global network of locations close to your users. See the options.