Apache Guacamole Part 1: Building the Remote Access Container

Part 1 of 3: container build, application stack, and authentication baseline.

Apache Guacamole provides remote desktop access through an ordinary web
browser. The goal of this series is to build a small self-hosted gateway that
can reach approved private machines without exposing RDP or other remote
desktop ports directly to the internet.

This first part creates the gateway in an unprivileged Ubuntu 24.04 LXD system
container. Docker Compose runs Guacamole, guacd, and PostgreSQL inside that
container. TOTP protects the Guacamole login, and separate Docker networks
limit which components can reach the database, web application, and eventual
remote desktop targets.

The complete series is:

  1. Part 1: build and validate the private Guacamole container;
  2. Part 2: publish the gateway securely through Nginx and TLS;
  3. Part 3: allow and test a least-privilege Windows RDP connection.

The examples use fictional private addresses and generic names. No real
hostnames, usernames, credentials, or infrastructure identifiers are included.

Why build it this way? Guacamole is an authentication gateway with access
to private systems. Keeping it in its own unprivileged container, isolating
its database, and allowing only explicitly approved network paths reduces the
impact of either a configuration mistake or an application compromise.

Architecture

The completed stack uses:

  • an unprivileged Ubuntu 24.04 LXD container;
  • Docker Engine and Docker Compose inside LXD;
  • Apache Guacamole and guacd 1.6.0;
  • PostgreSQL 17;
  • TOTP second-factor authentication;
  • an internal Docker backend network;
  • a separate guacd remote-access network;
  • persistent PostgreSQL data and RDP certificate trust;
  • one private HTTP listener for the later Nginx reverse proxy.

PostgreSQL and port 4822 on guacd are never published.

Internet access is added in Part 2
                 |
                 v
       private HTTP listener
                 |
        +--------+---------+
        | Guacamole web    |
        +--------+---------+
                 |
        internal Docker network
          |              |
          v              v
     PostgreSQL        guacd
                         |
                controlled target network
                         |
                approved VMs in Part 3

The distinction between the two Docker networks matters. The internal backend
lets the application components communicate but has no route to the LAN. The
controlled target network gives only guacd a stable source address that can
later be matched by endpoint-specific firewall rules.

1. Choose Local Values

The examples use neutral private addresses and generic resource names. Replace
them with values suitable for the local environment:

INSTANCE=guacamole-gateway
STORAGE_POOL=lxd-pool
LAN_BRIDGE=br0
GUAC_IP=10.20.30.223
GATEWAY_IP=10.20.30.1
PROXY_IP=10.20.30.220

The remote-access Docker subnet must not overlap any LAN, VPN, LXD, libvirt,
or existing Docker network. An overlap can send traffic toward the wrong
interface or make a private target unreachable. This guide uses:

172.20.0.0/24

Check the candidate container address from the LXD host:

ping -c 2 "$GUAC_IP"
ip neigh show "$GUAC_IP"

Expected result:

no ICMP response
no valid neighbour entry

Also reserve or exclude the address in DHCP before assigning it statically.

2. Create the LXD Container

The container receives two CPUs and 2 GiB of memory. These are limits, not
permanently reserved resources: the host scheduler can still use the same CPU
capacity for other workloads when Guacamole is idle.

Run on the LXD host:

lxc init ubuntu:24.04 "$INSTANCE" \
  --storage "$STORAGE_POOL" \
  --config limits.cpu=2 \
  --config limits.memory=2GiB \
  --config security.nesting=true \
  --config security.syscalls.intercept.mknod=true \
  --config security.syscalls.intercept.setxattr=true \
  --config boot.autostart=true

lxc config device set "$INSTANCE" root size 20GiB

lxc config device override "$INSTANCE" eth0 \
  nictype=bridged \
  parent="$LAN_BRIDGE" \
  name=eth0

lxc config set "$INSTANCE" cloud-init.network-config "$(cat <<EOF
version: 2
ethernets:
  eth0:
    dhcp4: false
    addresses:
      - ${GUAC_IP}/24
    routes:
      - to: default
        via: ${GATEWAY_IP}
    nameservers:
      addresses:
        - ${GATEWAY_IP}
        - 1.1.1.1
EOF
)"

Inspect the complete configuration before starting:

lxc config show "$INSTANCE" --expanded

Confirm:

limits.cpu: "2"
limits.memory: 2GiB
security.nesting: "true"
security.syscalls.intercept.mknod: "true"
security.syscalls.intercept.setxattr: "true"
root disk size: 20GiB
eth0: bridged to the intended LAN bridge

Start the container:

lxc start "$INSTANCE"
lxc exec "$INSTANCE" -- cloud-init status --wait

Expected final cloud-init state:

status: done

3. Validate the Container

Check identity, networking, storage, and memory:

lxc exec "$INSTANCE" -- hostnamectl
lxc exec "$INSTANCE" -- ip -brief address
lxc exec "$INSTANCE" -- ip route
lxc exec "$INSTANCE" -- ping -c 2 "$GATEWAY_IP"
lxc exec "$INSTANCE" -- ping -c 2 "$PROXY_IP"
lxc exec "$INSTANCE" -- getent hosts archive.ubuntu.com
lxc exec "$INSTANCE" -- curl -I https://archive.ubuntu.com
lxc exec "$INSTANCE" -- df -hT /
lxc exec "$INSTANCE" -- free -h

Required results:

  • Ubuntu 24.04 is reported;
  • eth0 has the chosen static address;
  • the default route uses the chosen gateway;
  • the gateway and reverse proxy answer;
  • DNS resolves;
  • outbound HTTPS returns a successful response;
  • the root filesystem reflects the intended quota;
  • the memory limit is approximately 2 GiB.

Stop here if the static route, DNS, or outbound HTTPS checks fail.

4. Install Docker Engine

Open an interactive root console:

lxc exec "$INSTANCE" -- bash

The remaining commands are entered directly at the container prompt.

Update Ubuntu:

set -euo pipefail
export DEBIAN_FRONTEND=noninteractive

apt-get update
apt-get -y full-upgrade
apt-get -y install ca-certificates curl

Configure Docker's official Ubuntu repository:

install -m 0755 -d /etc/apt/keyrings

curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
  -o /etc/apt/keyrings/docker.asc

chmod a+r /etc/apt/keyrings/docker.asc

. /etc/os-release

printf "%s\n" \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu ${UBUNTU_CODENAME:-$VERSION_CODENAME} stable" \
  > /etc/apt/sources.list.d/docker.list

apt-get update

Install and start Docker:

apt-get -y install \
  docker-ce \
  docker-ce-cli \
  containerd.io \
  docker-buildx-plugin \
  docker-compose-plugin

systemctl enable --now docker

Validate the installation:

docker version
docker compose version
systemctl is-enabled docker
systemctl is-active docker
docker info --format "Storage driver: {{.Driver}}"
docker info --format "{{json .DriverStatus}}"
docker info --format "{{.DockerRootDir}}"
docker info --format "{{json .SecurityOptions}}"

Expected key results:

Docker client and server versions are shown
Docker Compose version is shown
enabled
active
storage uses overlayfs, overlay2, or an overlay snapshotter
/var/lib/docker
AppArmor and seccomp are listed

Do not continue if Docker reports the vfs storage driver.

Exit to the LXD host and take a rollback snapshot:

exit
lxc snapshot "$INSTANCE" post-docker-baseline
lxc exec "$INSTANCE" -- bash

5. Prepare Application Files

Application state is separated from the Compose definition. PostgreSQL data
and FreeRDP trust information live in named volumes, while the database
password is supplied through a file-backed Docker secret. This keeps the
password out of the Compose file and normal container environment output.

Create the application, initialization, and secret directories:

GUAC_IP=10.20.30.223

install -d -m 0750 /opt/guacamole/init
install -d -m 0700 /opt/guacamole/secrets
cd /opt/guacamole

Generate a newline-free database password without displaying it:

umask 077

openssl rand -base64 48 | tr -d '\r\n' \
  > secrets/postgres_password

chmod 0644 secrets/postgres_password

The file is readable because the Guacamole container runs as a non-root user,
but its parent directory remains accessible only to root.

Validate only the length:

test "$(wc -c < secrets/postgres_password)" -eq 64 &&
  echo "PostgreSQL password file is valid"

Expected:

PostgreSQL password file is valid

Pull fixed image versions:

docker pull guacamole/guacamole:1.6.0
docker pull guacamole/guacd:1.6.0
docker pull postgres:17-alpine

Each pull must finish successfully with a digest or an up-to-date message.

Generate the matching PostgreSQL schema:

docker run --rm \
  guacamole/guacamole:1.6.0 \
  /opt/guacamole/bin/initdb.sh --postgresql \
  > init/001-guacamole.sql

chmod 0644 init/001-guacamole.sql

test -s init/001-guacamole.sql &&
  echo "Guacamole database schema generated"

grep -c 'CREATE TABLE' init/001-guacamole.sql

Expected:

Guacamole database schema generated
a non-zero CREATE TABLE count

6. Check Docker Subnets

Before assigning the controlled guacd subnet:

ip -4 route

docker network inspect $(docker network ls -q) \
  --format '{{.Name}}: {{range .IPAM.Config}}{{.Subnet}} {{end}}'

Confirm that 172.20.0.0/24 does not appear in either result. Select another
unused private subnet if it does.

7. Create the Compose Stack

The Compose file deliberately gives each component only the networks and
published ports it needs:

  • PostgreSQL uses only the internal backend;
  • guacd uses the backend and controlled target network;
  • the Guacamole web application uses the backend and frontend;
  • only the web application publishes a private LAN listener.

Create a local environment file using the container's private LAN address:

cat > .env <<EOF
GUACAMOLE_BIND_ADDRESS=${GUAC_IP}
EOF

chmod 0640 .env

Create compose.yaml:

cat > compose.yaml <<'EOF'
name: guacamole

services:
  postgres:
    image: postgres:17-alpine
    container_name: guacamole-postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: guacamole_db
      POSTGRES_USER: guacamole_user
      POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
    secrets:
      - postgres_password
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./init/001-guacamole.sql:/docker-entrypoint-initdb.d/001-guacamole.sql:ro
    networks:
      - backend
    healthcheck:
      test:
        - CMD-SHELL
        - pg_isready -U guacamole_user -d guacamole_db
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 20s
    security_opt:
      - no-new-privileges:true

  guacd:
    image: guacamole/guacd:1.6.0
    container_name: guacamole-guacd
    restart: unless-stopped
    volumes:
      - guacd_freerdp:/home/guacd/.config/freerdp
    networks:
      backend:
      remote_access:
        ipv4_address: 172.20.0.2
    security_opt:
      - no-new-privileges:true

  guacamole:
    image: guacamole/guacamole:1.6.0
    container_name: guacamole-web
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
      guacd:
        condition: service_started
    environment:
      WEBAPP_CONTEXT: ROOT
      GUACD_HOSTNAME: guacd
      GUACD_PORT: "4822"
      POSTGRESQL_ENABLED: "true"
      POSTGRESQL_HOSTNAME: postgres
      POSTGRESQL_PORT: "5432"
      POSTGRESQL_DATABASE: guacamole_db
      POSTGRESQL_USERNAME: guacamole_user
      POSTGRESQL_PASSWORD_FILE: /run/secrets/postgres_password
      TOTP_ENABLED: "true"
      REMOTE_IP_VALVE_ENABLED: "true"
      REMOTE_IP_VALVE_REMOTE_IP_HEADER: x-forwarded-for
      REMOTE_IP_VALVE_PROTOCOL_HEADER: x-forwarded-proto
      REMOTE_IP_VALVE_PROTOCOL_HEADER_HTTPS_VALUE: https
      REMOTE_IP_VALVE_HTTP_SERVER_PORT: "80"
      REMOTE_IP_VALVE_HTTPS_SERVER_PORT: "443"
    secrets:
      - postgres_password
    ports:
      - "${GUACAMOLE_BIND_ADDRESS}:8080:8080"
    networks:
      - frontend
      - backend
    security_opt:
      - no-new-privileges:true

secrets:
  postgres_password:
    file: ./secrets/postgres_password

volumes:
  postgres_data:
  guacd_freerdp:

networks:
  frontend:
  backend:
    internal: true
  remote_access:
    name: guacamole_remote_access
    driver: bridge
    driver_opts:
      com.docker.network.bridge.name: br-guac-remote
    ipam:
      config:
        - subnet: 172.20.0.0/24
          gateway: 172.20.0.1
EOF

The network roles are:

Network Attached services Purpose
frontend Guacamole web published private HTTP listener
backend PostgreSQL, guacd, Guacamole web internal application traffic
remote_access guacd only control point for outbound remote protocols

The remote-access network is created now, but its destination allowlist is
endpoint-dependent. Do not configure VM connections until the firewall step in
The controlled remote_access network is created now, but no target is
reachable through it until Part 3 adds an explicit firewall allowlist.

8. Validate and Start

The first start creates the database schema and named volumes. The checks below
confirm both application health and the network boundaries before any public
DNS or reverse proxy is introduced.

Validate the resolved Compose model:

docker compose config

Review the output and confirm:

  • no plaintext password appears;
  • only guacamole has a ports entry;
  • PostgreSQL and guacd have no published ports;
  • backend is internal;
  • guacd is attached to backend and remote_access;
  • guacd uses 172.20.0.2 on remote_access;
  • guacd_freerdp is mounted at /home/guacd/.config/freerdp.

Start the stack:

docker compose up -d

Expected creation sequence includes:

Network guacamole_frontend created
Network guacamole_backend created
Network guacamole_remote_access created
Volume guacamole_postgres_data created
Volume guacamole_guacd_freerdp created
Container guacamole-postgres healthy
Container guacamole-guacd started
Container guacamole-web started

Check container state:

docker compose ps

Expected:

guacamole-postgres   Up and healthy
guacamole-guacd      Up
guacamole-web        Up
only guacamole-web publishes the private address on port 8080

Initialize the persistent FreeRDP trust directory before creating any RDP
connection. A new named volume may initially be owned by root, while the
official guacd image runs as UID/GID 1000:

docker compose exec -T guacd id

docker compose exec -T --user 0 guacd sh -c '
install -d -o 1000 -g 1000 -m 0700 \
  /home/guacd/.config/freerdp
'

docker compose exec -T guacd sh -c '
touch /home/guacd/.config/freerdp/.write-test &&
rm /home/guacd/.config/freerdp/.write-test &&
echo "FreeRDP trust directory is writable"
'

Expected:

uid=1000(guacd) gid=1000(guacd)
FreeRDP trust directory is writable

Stop here if the write test fails. Trust on First Use cannot persist the RDP
certificate if guacd cannot write this directory, and the resulting failure
may be reported only as an RDP security-negotiation error.

Review startup logs:

docker compose logs --tail=100 postgres
docker compose logs --tail=100 guacd
docker compose logs --tail=100 guacamole

Expected key messages:

PostgreSQL: database system is ready to accept connections
guacd: Guacamole proxy daemon version 1.6.0 started
guacd: Listening on host 0.0.0.0, port 4822
Guacamole: PostgreSQL Authentication loaded
Guacamole: TOTP TFA Authentication Backend loaded
Guacamole: WebSocket support loaded
Guacamole: web application has started

The PostgreSQL first-run local trust message is expected because PostgreSQL
is not published. A WADL/JAXB warning from Guacamole is also non-blocking.

Test the web listener:

curl -sS -o /dev/null -w 'HTTP %{http_code}\n' \
  "http://${GUAC_IP}:8080/"

ss -lntp | grep ':8080'

Expected:

HTTP 200
port 8080 listens only on the selected private address

Verify the controlled bridge and fixed guacd address:

ip -brief address show br-guac-remote

docker network inspect guacamole_remote_access \
  --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{println}}{{end}}'

Expected:

br-guac-remote has 172.20.0.1/24
guacamole-guacd has 172.20.0.2/24

Verify persistent RDP trust storage:

docker inspect guacamole-guacd \
  --format '{{range .Mounts}}{{println .Name .Destination}}{{end}}'

docker compose exec -T guacd sh -c '
stat -c "%U:%G %a %n" /home/guacd/.config/freerdp
'

Expected output includes:

guacamole_guacd_freerdp /home/guacd/.config/freerdp
guacd:guacd 700 /home/guacd/.config/freerdp

9. Secure the Bootstrap Administrator

Before public access is enabled:

  1. Open the private Guacamole URL.
  2. Sign in with the initial bootstrap administrator.
  3. Enrol TOTP.
  4. Replace the initial password immediately.
  5. Create a separately named permanent administrator.
  6. Grant only the required system administration permissions.
  7. Test the replacement account in a separate browser session.
  8. Enrol TOTP for the replacement account.
  9. Confirm it can administer users and connections.
  10. Disable the bootstrap administrator.
  11. Confirm the disabled account can no longer authenticate.

Do not record passwords or TOTP seeds in implementation notes.

10. Completion Checks

Part 1 is complete when:

  • the LXD container is unprivileged and resource-limited;
  • Docker uses an overlay-based storage driver;
  • PostgreSQL and guacd are not published;
  • the private Guacamole listener returns HTTP 200;
  • TOTP authentication is active;
  • the bootstrap administrator is disabled;
  • PostgreSQL data is persistent;
  • FreeRDP certificate trust is persistent;
  • the internal and remote-access networks are distinct;
  • no VM connection has been created before the Part 3 allowlist.

Next in the Series

Part 2 adds public DNS, a dedicated TLS certificate, Nginx WebSocket proxying,
real-client address handling, and restriction of the private web listener to
the reverse proxy.

The gateway remains private at the end of this article. That provides a clean
checkpoint for taking a container snapshot and testing recovery before an
internet-facing endpoint exists.

Continue Reading Part 2: Publishing the Gateway through Nginx

References

Tags:

Comments are closed

Latest Comments