Workload · Running a remote desktop gateway

Your desktops in any browser: Apache Guacamole on a server you control

To run Apache Guacamole yourself, you need one server with Docker, a small MariaDB or MySQL database for accounts, and an HTTPS address in front; your team then opens Windows desktops and Linux shells in a browser tab, with no app to install. The walkthrough below uses the project's official 1.6.0 images and takes care of the two things that hurt people most often: the factory admin password and a port Docker quietly opens to the world.

The short version
  1. One page in the browser for RDP, SSH and VNC machines
  2. Official images, version 1.6.0, pinned in Compose
  3. Factory admin retired before the site goes public
  4. Six-digit authenticator codes for every account
  5. Free to use: Apache License 2.0, no seat count

What you are setting up

Guacamole calls itself a clientless remote desktop gateway. You log in to a web page, pick a machine, and its screen or terminal appears in the tab. Behind that page sit two programs. The web application, written in Java, draws the login screen, checks who you are and keeps the list of connections. A small native service called guacd does the actual talking to your Windows and Linux machines in RDP, SSH or VNC, then streams the picture back. The two speak their own Guacamole protocol, so the browser never needs to know which kind of machine is at the other end.

A third piece, the database, remembers your users, connections, history and authenticator keys. The project's Docker guide treats it as the one container whose data must survive an upgrade; the other two can be thrown away and recreated.

Why give it a server of its own?

Because this box ends up holding the way into every other machine you own. Keeping it apart from your websites and experiments means a bug in one of them cannot hand someone your desktops. The project does not publish memory or processor figures. Its 1.6.0 notes say the redesigned display pipeline makes better use of the server's processor, so sessions feel quicker and send less data; therefore in our view the processor is what you buy more of as more people work at once, and disk space is what recorded sessions eat.

Our dedicated servers hand you the whole machine, with root access and an unmetered port, so a busy week of screen sharing never turns into a bandwidth bill. A ready server is yours within 30 minutes of the payment being approved. Rack servers, blades and two cartridge models (m510, m710x) include out-of-band console access, meaning the screen and power button, still working when a firewall mistake has locked SSH out. On the remaining cartridges you get the power button alone. You manage the server; we keep the hardware and the network running. Month to month, no contract.

Step 1: close the doors, then prepare the files

One thing catches almost everyone: Docker opens published ports through its own firewall rules, skipping ufw entirely. A port written as 8080:8080 is published on every IPv4 address whatever ufw says, and on IPv6 too if Docker's IPv6 support is turned on. You will publish Guacamole on 127.0.0.1 only, and switch the firewall on first so SSH is all that answers. With Docker and the Compose plugin already installed:

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 /srv/guac/schema /srv/guac/rec
cd /srv/guac
sudo docker run --rm guacamole/guacamole:1.6.0 /opt/guacamole/bin/initdb.sh --mysql | sudo tee schema/01-guacamole.sql >/dev/null
sudo chmod 644 schema/01-guacamole.sql
sudo chown 1001:1000 rec && sudo chmod 2750 rec
sudo sh -c 'umask 077; pw=$(openssl rand -hex 24); printf "MARIADB_PASSWORD=%s\nMYSQL_PASSWORD=%s\n" "$pw" "$pw" > secrets.env'
sudo chmod 600 secrets.env

What that did: IPv6 is switched on in ufw before the SSH rule, so the rule protects both address types. The schema comes straight from the image's own initdb.sh script, because Guacamole never creates its tables by itself. The manual's Compose recipe hands the schema over through a named initdb volume; saving it to a file and mounting that file, as here, is our own variant with the same result. A random database password lands in secrets.env, which only root can read, so it never shows up in your shell history. It holds the same value twice because the two containers look for different names: MariaDB reads MARIADB_PASSWORD and Guacamole reads MYSQL_PASSWORD. The rec folder gets the owners the manual asks for: guacd (user 1001) writes recordings, the web app (group 1000) reads them.

Step 2: start the three containers

Save this as /srv/guac/compose.yml. MariaDB loads the schema file automatically the first time it finds an empty data volume. The mariadb:11.4 tag is our choice; the manual does not name a version. WEBAPP_CONTEXT: ROOT serves Guacamole at the top of your address rather than under /guacamole/.

services:
  guacd:
    image: guacamole/guacd:1.6.0
    restart: unless-stopped
    volumes:
      - ./rec:/var/lib/guacamole/recordings
  db:
    image: mariadb:11.4
    restart: unless-stopped
    env_file: secrets.env
    environment:
      MARIADB_DATABASE: guacamole_db
      MARIADB_USER: guacamole_user
      MARIADB_RANDOM_ROOT_PASSWORD: "1"
    volumes:
      - dbdata:/var/lib/mysql
      - ./schema:/docker-entrypoint-initdb.d:ro
  web:
    image: guacamole/guacamole:1.6.0
    restart: unless-stopped
    depends_on: [guacd, db]
    env_file: secrets.env
    environment:
      GUACD_HOSTNAME: guacd
      MYSQL_HOSTNAME: db
      MYSQL_DATABASE: guacamole_db
      MYSQL_USERNAME: guacamole_user
      TOTP_ENABLED: "true"
      RECORDING_ENABLED: "true"
      REMOTE_IP_VALVE_ENABLED: "true"
      WEBAPP_CONTEXT: ROOT
    volumes:
      - ./rec:/var/lib/guacamole/recordings:ro
    ports:
      - "127.0.0.1:8080:8080"
volumes:
  dbdata:

Run sudo docker compose up -d from /srv/guac, then check with sudo ss -tlnp | grep 8080: you want to see 127.0.0.1 and nothing else. The database prints a random root password once in its log; Guacamole never needs it. Nothing listens for guacd outside Docker's private network, which is how it must stay: guacd checks no passwords, and the manual warns it could become a stepping stone into your other systems.

Step 3: retire the factory admin, privately

The schema ships with a single account: username guacadmin, password guacadmin. Anybody who knows that, and it is in the manual, owns your gateway if they reach the page first. The manual's instruction is to change that password immediately; signing in through an SSH tunnel while the page is still invisible to the internet, and deleting the account later, is our advice on top:

  1. On your own computer run ssh -N -L 18080:127.0.0.1:8080 you@gateway-ip and leave it open.
  2. Browse to http://localhost:18080/ and log in as guacadmin. With TOTP_ENABLED on, it asks you to scan a QR code with an authenticator app straight away.
  3. In Settings, add a user for yourself with all system permissions and a long, unique password.
  4. Log out, log in as yourself, scan your own QR code. Now remove the guacadmin account entirely (our recommendation) or, at the very minimum the manual asks for, change its password.

From then on every account gets a six-digit code that changes every 30 seconds, which Google Authenticator and similar apps read without any setting changes. If someone loses their phone, tick "Clear TOTP secret" on their account and they enrol again at next login. Version 1.6.0 also lets you exempt a user or group. Prefer one sign-in for all your tools? The OpenID Connect and SAML extensions can hand login to authentik or Keycloak, which then handle the second factor themselves.

Step 4: give it an HTTPS address

Guacamole's manual says production installs belong behind a reverse proxy that handles the certificate. With nginx, two details matter: the WebSocket headers must be passed on explicitly, and buffering must be off, or sessions freeze. Once your certificate exists:

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

    location / {
        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;
    }
}

Let web traffic in with sudo ufw allow 'Nginx Full' and visit your new address. The client_max_body_size line lifts nginx's 1 MB upload limit for file transfers; pick your own figure. REMOTE_IP_VALVE_ENABLED in the Compose file lets Guacamole log each person's real address instead of the proxy's; out of the box it only believes that header when it comes from a private address, as the manual describes. We read that as covering nginx on the same host, whose requests enter the container via the Docker bridge network, from a private address. If you still see the proxy's address in the logs, point PROXY_ALLOWED_IPS_REGEX at the bridge gateway address. That matters because the built-in lockout (already active in the Docker image) blocks an address for five minutes after five wrong passwords. Rather keep the login off the open internet altogether? Put the team on a WireGuard VPN and skip the public listener.

Step 5: add machines, and decide what to record

Each connection is a protocol plus a handful of parameters, set in the admin screens:

  • Windows over RDP: hostname, and port only if it is not 3389. Leave security on any to negotiate, or choose nla. Without saved credentials, NLA makes Guacamole ask for them; in other modes the Windows sign-in screen appears instead. domain, for a Windows domain login, exists for RDP only. A self-signed certificate needs ignore-cert, or cert-tofu to trust it on first sight and check it afterwards.
  • Linux over SSH: hostname, port if not 22, and either a password or a private-key in OpenSSH format. Paste the server's known_hosts line into host-key; without it Guacamole does not verify who it is talking to.
  • Recording: set the recording path to ${HISTORY_PATH}/${HISTORY_UUID} and tick "automatically create path", the layout the manual recommends; recordings then play back from the connection's history. SSH sessions can also save a plain-text transcript via typescript-path.

Logging keystrokes (recording-include-keys) records everything typed, passwords included. Switch it on only when you truly need it.

Running a Windows RDP server for your team? With Guacamole in front, its port 3389 can be closed to everyone except the gateway.

Keeping it healthy

Back up the database and the rec folder, and keep the copies on another machine. As root, make the folder once with install -d -m 700 /srv/guac-dumps, then cd /srv/guac && docker compose exec -T db sh -c 'MYSQL_PWD="$MARIADB_PASSWORD" mariadb-dump --single-transaction -u guacamole_user guacamole_db' > /srv/guac-dumps/db-$(date +%F).sql writes the dump there (in crontab, write each % as \%).

Upgrades go in this order. Read the release notes and take a dump. If the schema changed, the upgrade-pre-VERSION.sql files sit in the mysql/schema/upgrade/ folder of the new release's guacamole-auth-jdbc download; moving to 1.6.0 itself requires upgrade-pre-1.6.0.sql. Stop the web container (docker compose stop web) and apply every script that applies, oldest first: docker compose exec -T db sh -c 'MYSQL_PWD="$MARIADB_PASSWORD" mariadb -u guacamole_user guacamole_db' < upgrade-pre-1.6.0.sql. Only after that raise both Guacamole image tags to the same new version and run docker compose pull and docker compose up -d, so the new web app never meets the old schema.

The cost of the software is nothing: Guacamole is an Apache Software Foundation project under the Apache License 2.0, with community help on its mailing lists and paid help from outside companies. Where it fits less well: if everyone already has a VPN and a desktop client they like, it adds a layer without adding much. And if the gateway goes down, so does every remote session through it; it also needs a network path to each machine it opens.

Questions people ask

What are Guacamole's default login details?
guacadmin as the username and guacadmin as the password, created by the database schema. The manual says to change the password the moment your first login works; we suggest doing it through an SSH tunnel so nobody else can race you to the page.
Do my users need to install anything?
No. A current web browser is all they need, which is why the project calls Guacamole clientless. The machines they connect to keep running their usual RDP, SSH or VNC services; only the gateway talks to them.
Should I pick MariaDB or PostgreSQL?
Either works, and both are first-class in the manual. This page uses MariaDB; for PostgreSQL you would run initdb.sh --postgresql and use the POSTGRESQL_ settings instead. Pick whichever one you already know how to back up.
Can I run Guacamole and the machines it connects to on one server?
You can, for example with virtual machines on the same box, but the gateway is safer on separate hardware. If it is ever compromised, you want the damage to stop at the gateway rather than at the desktops behind it.
How do I keep strangers away from the login page?
Turn on two-factor codes for everyone, keep the built-in lockout on, and serve the page only over HTTPS. For a small team, putting the login behind a WireGuard VPN removes the public page entirely.

Get a server for your remote desktop gateway

A whole machine, root access and an unmetered port, ready within 30 minutes of approval and billed month to month.

Choose your server

Read next