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:
- Part 1: build and validate the private Guacamole container;
- Part 2: publish the gateway securely through Nginx and TLS;
- 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
guacd1.6.0; - PostgreSQL 17;
- TOTP second-factor authentication;
- an internal Docker backend network;
- a separate
guacdremote-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;
eth0has 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;
guacduses 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
guacamolehas aportsentry; - PostgreSQL and
guacdhave no published ports; backendis internal;guacdis attached tobackendandremote_access;guacduses172.20.0.2onremote_access;guacd_freerdpis 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:
- Open the private Guacamole URL.
- Sign in with the initial bootstrap administrator.
- Enrol TOTP.
- Replace the initial password immediately.
- Create a separately named permanent administrator.
- Grant only the required system administration permissions.
- Test the replacement account in a separate browser session.
- Enrol TOTP for the replacement account.
- Confirm it can administer users and connections.
- Disable the bootstrap administrator.
- 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
guacdare 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
Comments are closed