{"id":195,"date":"2026-07-28T06:16:47","date_gmt":"2026-07-28T06:16:47","guid":{"rendered":"https:\/\/www.miniamju.com\/index.php\/apache-guacamole-part-1-building-the-remote-access-container\/"},"modified":"2026-07-28T06:16:47","modified_gmt":"2026-07-28T06:16:47","slug":"apache-guacamole-part-1-building-the-remote-access-container","status":"publish","type":"post","link":"https:\/\/www.miniamju.com\/index.php\/apache-guacamole-part-1-building-the-remote-access-container\/","title":{"rendered":"Apache Guacamole Part 1: Building the Remote Access Container"},"content":{"rendered":"<p><em>Part 1 of 3: container build, application stack, and authentication baseline.<\/em><\/p>\n<p>Apache Guacamole provides remote desktop access through an ordinary web<br \/>\nbrowser. The goal of this series is to build a small self-hosted gateway that<br \/>\ncan reach approved private machines without exposing RDP or other remote<br \/>\ndesktop ports directly to the internet.<\/p>\n<p>This first part creates the gateway in an unprivileged Ubuntu 24.04 LXD system<br \/>\ncontainer. Docker Compose runs Guacamole, <code>guacd<\/code>, and PostgreSQL inside that<br \/>\ncontainer. TOTP protects the Guacamole login, and separate Docker networks<br \/>\nlimit which components can reach the database, web application, and eventual<br \/>\nremote desktop targets.<\/p>\n<p>The complete series is:<\/p>\n<ol>\n<li><strong>Part 1:<\/strong> build and validate the private Guacamole container;<\/li>\n<li><strong>Part 2:<\/strong> publish the gateway securely through Nginx and TLS;<\/li>\n<li><strong>Part 3:<\/strong> allow and test a least-privilege Windows RDP connection.<\/li>\n<\/ol>\n<p>The examples use fictional private addresses and generic names. No real<br \/>\nhostnames, usernames, credentials, or infrastructure identifiers are included.<\/p>\n<blockquote>\n<p><strong>Why build it this way?<\/strong> Guacamole is an authentication gateway with access<br \/>\nto private systems. Keeping it in its own unprivileged container, isolating<br \/>\nits database, and allowing only explicitly approved network paths reduces the<br \/>\nimpact of either a configuration mistake or an application compromise.<\/p>\n<\/blockquote>\n<h2>Architecture<\/h2>\n<p>The completed stack uses:<\/p>\n<ul>\n<li>an unprivileged Ubuntu 24.04 LXD container;<\/li>\n<li>Docker Engine and Docker Compose inside LXD;<\/li>\n<li>Apache Guacamole and <code>guacd<\/code> 1.6.0;<\/li>\n<li>PostgreSQL 17;<\/li>\n<li>TOTP second-factor authentication;<\/li>\n<li>an internal Docker backend network;<\/li>\n<li>a separate <code>guacd<\/code> remote-access network;<\/li>\n<li>persistent PostgreSQL data and RDP certificate trust;<\/li>\n<li>one private HTTP listener for the later Nginx reverse proxy.<\/li>\n<\/ul>\n<p>PostgreSQL and port 4822 on <code>guacd<\/code> are never published.<\/p>\n<pre><code class=\"language-text\">Internet access is added in Part 2\n                 |\n                 v\n       private HTTP listener\n                 |\n        +--------+---------+\n        | Guacamole web    |\n        +--------+---------+\n                 |\n        internal Docker network\n          |              |\n          v              v\n     PostgreSQL        guacd\n                         |\n                controlled target network\n                         |\n                approved VMs in Part 3\n<\/code><\/pre>\n<p>The distinction between the two Docker networks matters. The internal backend<br \/>\nlets the application components communicate but has no route to the LAN. The<br \/>\ncontrolled target network gives only <code>guacd<\/code> a stable source address that can<br \/>\nlater be matched by endpoint-specific firewall rules.<\/p>\n<h2>1. Choose Local Values<\/h2>\n<p>The examples use neutral private addresses and generic resource names. Replace<br \/>\nthem with values suitable for the local environment:<\/p>\n<pre><code class=\"language-bash\">INSTANCE=guacamole-gateway\nSTORAGE_POOL=lxd-pool\nLAN_BRIDGE=br0\nGUAC_IP=10.20.30.223\nGATEWAY_IP=10.20.30.1\nPROXY_IP=10.20.30.220\n<\/code><\/pre>\n<p>The remote-access Docker subnet must not overlap any LAN, VPN, LXD, libvirt,<br \/>\nor existing Docker network. An overlap can send traffic toward the wrong<br \/>\ninterface or make a private target unreachable. This guide uses:<\/p>\n<pre><code class=\"language-text\">172.20.0.0\/24\n<\/code><\/pre>\n<p>Check the candidate container address from the LXD host:<\/p>\n<pre><code class=\"language-bash\">ping -c 2 &quot;$GUAC_IP&quot;\nip neigh show &quot;$GUAC_IP&quot;\n<\/code><\/pre>\n<p>Expected result:<\/p>\n<pre><code class=\"language-text\">no ICMP response\nno valid neighbour entry\n<\/code><\/pre>\n<p>Also reserve or exclude the address in DHCP before assigning it statically.<\/p>\n<h2>2. Create the LXD Container<\/h2>\n<p>The container receives two CPUs and 2 GiB of memory. These are limits, not<br \/>\npermanently reserved resources: the host scheduler can still use the same CPU<br \/>\ncapacity for other workloads when Guacamole is idle.<\/p>\n<p>Run on the LXD host:<\/p>\n<pre><code class=\"language-bash\">lxc init ubuntu:24.04 &quot;$INSTANCE&quot; \\\n  --storage &quot;$STORAGE_POOL&quot; \\\n  --config limits.cpu=2 \\\n  --config limits.memory=2GiB \\\n  --config security.nesting=true \\\n  --config security.syscalls.intercept.mknod=true \\\n  --config security.syscalls.intercept.setxattr=true \\\n  --config boot.autostart=true\n\nlxc config device set &quot;$INSTANCE&quot; root size 20GiB\n\nlxc config device override &quot;$INSTANCE&quot; eth0 \\\n  nictype=bridged \\\n  parent=&quot;$LAN_BRIDGE&quot; \\\n  name=eth0\n\nlxc config set &quot;$INSTANCE&quot; cloud-init.network-config &quot;$(cat &lt;&lt;EOF\nversion: 2\nethernets:\n  eth0:\n    dhcp4: false\n    addresses:\n      - ${GUAC_IP}\/24\n    routes:\n      - to: default\n        via: ${GATEWAY_IP}\n    nameservers:\n      addresses:\n        - ${GATEWAY_IP}\n        - 1.1.1.1\nEOF\n)&quot;\n<\/code><\/pre>\n<p>Inspect the complete configuration before starting:<\/p>\n<pre><code class=\"language-bash\">lxc config show &quot;$INSTANCE&quot; --expanded\n<\/code><\/pre>\n<p>Confirm:<\/p>\n<pre><code class=\"language-text\">limits.cpu: &quot;2&quot;\nlimits.memory: 2GiB\nsecurity.nesting: &quot;true&quot;\nsecurity.syscalls.intercept.mknod: &quot;true&quot;\nsecurity.syscalls.intercept.setxattr: &quot;true&quot;\nroot disk size: 20GiB\neth0: bridged to the intended LAN bridge\n<\/code><\/pre>\n<p>Start the container:<\/p>\n<pre><code class=\"language-bash\">lxc start &quot;$INSTANCE&quot;\nlxc exec &quot;$INSTANCE&quot; -- cloud-init status --wait\n<\/code><\/pre>\n<p>Expected final cloud-init state:<\/p>\n<pre><code class=\"language-text\">status: done\n<\/code><\/pre>\n<h2>3. Validate the Container<\/h2>\n<p>Check identity, networking, storage, and memory:<\/p>\n<pre><code class=\"language-bash\">lxc exec &quot;$INSTANCE&quot; -- hostnamectl\nlxc exec &quot;$INSTANCE&quot; -- ip -brief address\nlxc exec &quot;$INSTANCE&quot; -- ip route\nlxc exec &quot;$INSTANCE&quot; -- ping -c 2 &quot;$GATEWAY_IP&quot;\nlxc exec &quot;$INSTANCE&quot; -- ping -c 2 &quot;$PROXY_IP&quot;\nlxc exec &quot;$INSTANCE&quot; -- getent hosts archive.ubuntu.com\nlxc exec &quot;$INSTANCE&quot; -- curl -I https:\/\/archive.ubuntu.com\nlxc exec &quot;$INSTANCE&quot; -- df -hT \/\nlxc exec &quot;$INSTANCE&quot; -- free -h\n<\/code><\/pre>\n<p>Required results:<\/p>\n<ul>\n<li>Ubuntu 24.04 is reported;<\/li>\n<li><code>eth0<\/code> has the chosen static address;<\/li>\n<li>the default route uses the chosen gateway;<\/li>\n<li>the gateway and reverse proxy answer;<\/li>\n<li>DNS resolves;<\/li>\n<li>outbound HTTPS returns a successful response;<\/li>\n<li>the root filesystem reflects the intended quota;<\/li>\n<li>the memory limit is approximately 2 GiB.<\/li>\n<\/ul>\n<p>Stop here if the static route, DNS, or outbound HTTPS checks fail.<\/p>\n<h2>4. Install Docker Engine<\/h2>\n<p>Open an interactive root console:<\/p>\n<pre><code class=\"language-bash\">lxc exec &quot;$INSTANCE&quot; -- bash\n<\/code><\/pre>\n<p>The remaining commands are entered directly at the container prompt.<\/p>\n<p>Update Ubuntu:<\/p>\n<pre><code class=\"language-bash\">set -euo pipefail\nexport DEBIAN_FRONTEND=noninteractive\n\napt-get update\napt-get -y full-upgrade\napt-get -y install ca-certificates curl\n<\/code><\/pre>\n<p>Configure Docker&#39;s official Ubuntu repository:<\/p>\n<pre><code class=\"language-bash\">install -m 0755 -d \/etc\/apt\/keyrings\n\ncurl -fsSL https:\/\/download.docker.com\/linux\/ubuntu\/gpg \\\n  -o \/etc\/apt\/keyrings\/docker.asc\n\nchmod a+r \/etc\/apt\/keyrings\/docker.asc\n\n. \/etc\/os-release\n\nprintf &quot;%s\\n&quot; \\\n  &quot;deb [arch=$(dpkg --print-architecture) signed-by=\/etc\/apt\/keyrings\/docker.asc] https:\/\/download.docker.com\/linux\/ubuntu ${UBUNTU_CODENAME:-$VERSION_CODENAME} stable&quot; \\\n  &gt; \/etc\/apt\/sources.list.d\/docker.list\n\napt-get update\n<\/code><\/pre>\n<p>Install and start Docker:<\/p>\n<pre><code class=\"language-bash\">apt-get -y install \\\n  docker-ce \\\n  docker-ce-cli \\\n  containerd.io \\\n  docker-buildx-plugin \\\n  docker-compose-plugin\n\nsystemctl enable --now docker\n<\/code><\/pre>\n<p>Validate the installation:<\/p>\n<pre><code class=\"language-bash\">docker version\ndocker compose version\nsystemctl is-enabled docker\nsystemctl is-active docker\ndocker info --format &quot;Storage driver: {{.Driver}}&quot;\ndocker info --format &quot;{{json .DriverStatus}}&quot;\ndocker info --format &quot;{{.DockerRootDir}}&quot;\ndocker info --format &quot;{{json .SecurityOptions}}&quot;\n<\/code><\/pre>\n<p>Expected key results:<\/p>\n<pre><code class=\"language-text\">Docker client and server versions are shown\nDocker Compose version is shown\nenabled\nactive\nstorage uses overlayfs, overlay2, or an overlay snapshotter\n\/var\/lib\/docker\nAppArmor and seccomp are listed\n<\/code><\/pre>\n<p>Do not continue if Docker reports the <code>vfs<\/code> storage driver.<\/p>\n<p>Exit to the LXD host and take a rollback snapshot:<\/p>\n<pre><code class=\"language-bash\">exit\nlxc snapshot &quot;$INSTANCE&quot; post-docker-baseline\nlxc exec &quot;$INSTANCE&quot; -- bash\n<\/code><\/pre>\n<h2>5. Prepare Application Files<\/h2>\n<p>Application state is separated from the Compose definition. PostgreSQL data<br \/>\nand FreeRDP trust information live in named volumes, while the database<br \/>\npassword is supplied through a file-backed Docker secret. This keeps the<br \/>\npassword out of the Compose file and normal container environment output.<\/p>\n<p>Create the application, initialization, and secret directories:<\/p>\n<pre><code class=\"language-bash\">GUAC_IP=10.20.30.223\n\ninstall -d -m 0750 \/opt\/guacamole\/init\ninstall -d -m 0700 \/opt\/guacamole\/secrets\ncd \/opt\/guacamole\n<\/code><\/pre>\n<p>Generate a newline-free database password without displaying it:<\/p>\n<pre><code class=\"language-bash\">umask 077\n\nopenssl rand -base64 48 | tr -d &#39;\\r\\n&#39; \\\n  &gt; secrets\/postgres_password\n\nchmod 0644 secrets\/postgres_password\n<\/code><\/pre>\n<p>The file is readable because the Guacamole container runs as a non-root user,<br \/>\nbut its parent directory remains accessible only to root.<\/p>\n<p>Validate only the length:<\/p>\n<pre><code class=\"language-bash\">test &quot;$(wc -c &lt; secrets\/postgres_password)&quot; -eq 64 &amp;&amp;\n  echo &quot;PostgreSQL password file is valid&quot;\n<\/code><\/pre>\n<p>Expected:<\/p>\n<pre><code class=\"language-text\">PostgreSQL password file is valid\n<\/code><\/pre>\n<p>Pull fixed image versions:<\/p>\n<pre><code class=\"language-bash\">docker pull guacamole\/guacamole:1.6.0\ndocker pull guacamole\/guacd:1.6.0\ndocker pull postgres:17-alpine\n<\/code><\/pre>\n<p>Each pull must finish successfully with a digest or an up-to-date message.<\/p>\n<p>Generate the matching PostgreSQL schema:<\/p>\n<pre><code class=\"language-bash\">docker run --rm \\\n  guacamole\/guacamole:1.6.0 \\\n  \/opt\/guacamole\/bin\/initdb.sh --postgresql \\\n  &gt; init\/001-guacamole.sql\n\nchmod 0644 init\/001-guacamole.sql\n\ntest -s init\/001-guacamole.sql &amp;&amp;\n  echo &quot;Guacamole database schema generated&quot;\n\ngrep -c &#39;CREATE TABLE&#39; init\/001-guacamole.sql\n<\/code><\/pre>\n<p>Expected:<\/p>\n<pre><code class=\"language-text\">Guacamole database schema generated\na non-zero CREATE TABLE count\n<\/code><\/pre>\n<h2>6. Check Docker Subnets<\/h2>\n<p>Before assigning the controlled <code>guacd<\/code> subnet:<\/p>\n<pre><code class=\"language-bash\">ip -4 route\n\ndocker network inspect $(docker network ls -q) \\\n  --format &#39;{{.Name}}: {{range .IPAM.Config}}{{.Subnet}} {{end}}&#39;\n<\/code><\/pre>\n<p>Confirm that <code>172.20.0.0\/24<\/code> does not appear in either result. Select another<br \/>\nunused private subnet if it does.<\/p>\n<h2>7. Create the Compose Stack<\/h2>\n<p>The Compose file deliberately gives each component only the networks and<br \/>\npublished ports it needs:<\/p>\n<ul>\n<li>PostgreSQL uses only the internal backend;<\/li>\n<li><code>guacd<\/code> uses the backend and controlled target network;<\/li>\n<li>the Guacamole web application uses the backend and frontend;<\/li>\n<li>only the web application publishes a private LAN listener.<\/li>\n<\/ul>\n<p>Create a local environment file using the container&#39;s private LAN address:<\/p>\n<pre><code class=\"language-bash\">cat &gt; .env &lt;&lt;EOF\nGUACAMOLE_BIND_ADDRESS=${GUAC_IP}\nEOF\n\nchmod 0640 .env\n<\/code><\/pre>\n<p>Create <code>compose.yaml<\/code>:<\/p>\n<pre><code class=\"language-bash\">cat &gt; compose.yaml &lt;&lt;&#39;EOF&#39;\nname: guacamole\n\nservices:\n  postgres:\n    image: postgres:17-alpine\n    container_name: guacamole-postgres\n    restart: unless-stopped\n    environment:\n      POSTGRES_DB: guacamole_db\n      POSTGRES_USER: guacamole_user\n      POSTGRES_PASSWORD_FILE: \/run\/secrets\/postgres_password\n    secrets:\n      - postgres_password\n    volumes:\n      - postgres_data:\/var\/lib\/postgresql\/data\n      - .\/init\/001-guacamole.sql:\/docker-entrypoint-initdb.d\/001-guacamole.sql:ro\n    networks:\n      - backend\n    healthcheck:\n      test:\n        - CMD-SHELL\n        - pg_isready -U guacamole_user -d guacamole_db\n      interval: 10s\n      timeout: 5s\n      retries: 10\n      start_period: 20s\n    security_opt:\n      - no-new-privileges:true\n\n  guacd:\n    image: guacamole\/guacd:1.6.0\n    container_name: guacamole-guacd\n    restart: unless-stopped\n    volumes:\n      - guacd_freerdp:\/home\/guacd\/.config\/freerdp\n    networks:\n      backend:\n      remote_access:\n        ipv4_address: 172.20.0.2\n    security_opt:\n      - no-new-privileges:true\n\n  guacamole:\n    image: guacamole\/guacamole:1.6.0\n    container_name: guacamole-web\n    restart: unless-stopped\n    depends_on:\n      postgres:\n        condition: service_healthy\n      guacd:\n        condition: service_started\n    environment:\n      WEBAPP_CONTEXT: ROOT\n      GUACD_HOSTNAME: guacd\n      GUACD_PORT: &quot;4822&quot;\n      POSTGRESQL_ENABLED: &quot;true&quot;\n      POSTGRESQL_HOSTNAME: postgres\n      POSTGRESQL_PORT: &quot;5432&quot;\n      POSTGRESQL_DATABASE: guacamole_db\n      POSTGRESQL_USERNAME: guacamole_user\n      POSTGRESQL_PASSWORD_FILE: \/run\/secrets\/postgres_password\n      TOTP_ENABLED: &quot;true&quot;\n      REMOTE_IP_VALVE_ENABLED: &quot;true&quot;\n      REMOTE_IP_VALVE_REMOTE_IP_HEADER: x-forwarded-for\n      REMOTE_IP_VALVE_PROTOCOL_HEADER: x-forwarded-proto\n      REMOTE_IP_VALVE_PROTOCOL_HEADER_HTTPS_VALUE: https\n      REMOTE_IP_VALVE_HTTP_SERVER_PORT: &quot;80&quot;\n      REMOTE_IP_VALVE_HTTPS_SERVER_PORT: &quot;443&quot;\n    secrets:\n      - postgres_password\n    ports:\n      - &quot;${GUACAMOLE_BIND_ADDRESS}:8080:8080&quot;\n    networks:\n      - frontend\n      - backend\n    security_opt:\n      - no-new-privileges:true\n\nsecrets:\n  postgres_password:\n    file: .\/secrets\/postgres_password\n\nvolumes:\n  postgres_data:\n  guacd_freerdp:\n\nnetworks:\n  frontend:\n  backend:\n    internal: true\n  remote_access:\n    name: guacamole_remote_access\n    driver: bridge\n    driver_opts:\n      com.docker.network.bridge.name: br-guac-remote\n    ipam:\n      config:\n        - subnet: 172.20.0.0\/24\n          gateway: 172.20.0.1\nEOF\n<\/code><\/pre>\n<p>The network roles are:<\/p>\n<table>\n<thead>\n<tr>\n<th>Network<\/th>\n<th>Attached services<\/th>\n<th>Purpose<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><code>frontend<\/code><\/td>\n<td>Guacamole web<\/td>\n<td>published private HTTP listener<\/td>\n<\/tr>\n<tr>\n<td><code>backend<\/code><\/td>\n<td>PostgreSQL, <code>guacd<\/code>, Guacamole web<\/td>\n<td>internal application traffic<\/td>\n<\/tr>\n<tr>\n<td><code>remote_access<\/code><\/td>\n<td><code>guacd<\/code> only<\/td>\n<td>control point for outbound remote protocols<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>The remote-access network is created now, but its destination allowlist is<br \/>\nendpoint-dependent. Do not configure VM connections until the firewall step in<br \/>\nThe controlled <code>remote_access<\/code> network is created now, but no target is<br \/>\nreachable through it until Part 3 adds an explicit firewall allowlist.<\/p>\n<h2>8. Validate and Start<\/h2>\n<p>The first start creates the database schema and named volumes. The checks below<br \/>\nconfirm both application health and the network boundaries before any public<br \/>\nDNS or reverse proxy is introduced.<\/p>\n<p>Validate the resolved Compose model:<\/p>\n<pre><code class=\"language-bash\">docker compose config\n<\/code><\/pre>\n<p>Review the output and confirm:<\/p>\n<ul>\n<li>no plaintext password appears;<\/li>\n<li>only <code>guacamole<\/code> has a <code>ports<\/code> entry;<\/li>\n<li>PostgreSQL and <code>guacd<\/code> have no published ports;<\/li>\n<li><code>backend<\/code> is internal;<\/li>\n<li><code>guacd<\/code> is attached to <code>backend<\/code> and <code>remote_access<\/code>;<\/li>\n<li><code>guacd<\/code> uses <code>172.20.0.2<\/code> on <code>remote_access<\/code>;<\/li>\n<li><code>guacd_freerdp<\/code> is mounted at <code>\/home\/guacd\/.config\/freerdp<\/code>.<\/li>\n<\/ul>\n<p>Start the stack:<\/p>\n<pre><code class=\"language-bash\">docker compose up -d\n<\/code><\/pre>\n<p>Expected creation sequence includes:<\/p>\n<pre><code class=\"language-text\">Network guacamole_frontend created\nNetwork guacamole_backend created\nNetwork guacamole_remote_access created\nVolume guacamole_postgres_data created\nVolume guacamole_guacd_freerdp created\nContainer guacamole-postgres healthy\nContainer guacamole-guacd started\nContainer guacamole-web started\n<\/code><\/pre>\n<p>Check container state:<\/p>\n<pre><code class=\"language-bash\">docker compose ps\n<\/code><\/pre>\n<p>Expected:<\/p>\n<pre><code class=\"language-text\">guacamole-postgres   Up and healthy\nguacamole-guacd      Up\nguacamole-web        Up\nonly guacamole-web publishes the private address on port 8080\n<\/code><\/pre>\n<p>Initialize the persistent FreeRDP trust directory before creating any RDP<br \/>\nconnection. A new named volume may initially be owned by root, while the<br \/>\nofficial <code>guacd<\/code> image runs as UID\/GID 1000:<\/p>\n<pre><code class=\"language-bash\">docker compose exec -T guacd id\n\ndocker compose exec -T --user 0 guacd sh -c &#39;\ninstall -d -o 1000 -g 1000 -m 0700 \\\n  \/home\/guacd\/.config\/freerdp\n&#39;\n\ndocker compose exec -T guacd sh -c &#39;\ntouch \/home\/guacd\/.config\/freerdp\/.write-test &amp;&amp;\nrm \/home\/guacd\/.config\/freerdp\/.write-test &amp;&amp;\necho &quot;FreeRDP trust directory is writable&quot;\n&#39;\n<\/code><\/pre>\n<p>Expected:<\/p>\n<pre><code class=\"language-text\">uid=1000(guacd) gid=1000(guacd)\nFreeRDP trust directory is writable\n<\/code><\/pre>\n<p>Stop here if the write test fails. Trust on First Use cannot persist the RDP<br \/>\ncertificate if <code>guacd<\/code> cannot write this directory, and the resulting failure<br \/>\nmay be reported only as an RDP security-negotiation error.<\/p>\n<p>Review startup logs:<\/p>\n<pre><code class=\"language-bash\">docker compose logs --tail=100 postgres\ndocker compose logs --tail=100 guacd\ndocker compose logs --tail=100 guacamole\n<\/code><\/pre>\n<p>Expected key messages:<\/p>\n<pre><code class=\"language-text\">PostgreSQL: database system is ready to accept connections\nguacd: Guacamole proxy daemon version 1.6.0 started\nguacd: Listening on host 0.0.0.0, port 4822\nGuacamole: PostgreSQL Authentication loaded\nGuacamole: TOTP TFA Authentication Backend loaded\nGuacamole: WebSocket support loaded\nGuacamole: web application has started\n<\/code><\/pre>\n<p>The PostgreSQL first-run local <code>trust<\/code> message is expected because PostgreSQL<br \/>\nis not published. A WADL\/JAXB warning from Guacamole is also non-blocking.<\/p>\n<p>Test the web listener:<\/p>\n<pre><code class=\"language-bash\">curl -sS -o \/dev\/null -w &#39;HTTP %{http_code}\\n&#39; \\\n  &quot;http:\/\/${GUAC_IP}:8080\/&quot;\n\nss -lntp | grep &#39;:8080&#39;\n<\/code><\/pre>\n<p>Expected:<\/p>\n<pre><code class=\"language-text\">HTTP 200\nport 8080 listens only on the selected private address\n<\/code><\/pre>\n<p>Verify the controlled bridge and fixed <code>guacd<\/code> address:<\/p>\n<pre><code class=\"language-bash\">ip -brief address show br-guac-remote\n\ndocker network inspect guacamole_remote_access \\\n  --format &#39;{{range .Containers}}{{.Name}} {{.IPv4Address}}{{println}}{{end}}&#39;\n<\/code><\/pre>\n<p>Expected:<\/p>\n<pre><code class=\"language-text\">br-guac-remote has 172.20.0.1\/24\nguacamole-guacd has 172.20.0.2\/24\n<\/code><\/pre>\n<p>Verify persistent RDP trust storage:<\/p>\n<pre><code class=\"language-bash\">docker inspect guacamole-guacd \\\n  --format &#39;{{range .Mounts}}{{println .Name .Destination}}{{end}}&#39;\n\ndocker compose exec -T guacd sh -c &#39;\nstat -c &quot;%U:%G %a %n&quot; \/home\/guacd\/.config\/freerdp\n&#39;\n<\/code><\/pre>\n<p>Expected output includes:<\/p>\n<pre><code class=\"language-text\">guacamole_guacd_freerdp \/home\/guacd\/.config\/freerdp\nguacd:guacd 700 \/home\/guacd\/.config\/freerdp\n<\/code><\/pre>\n<h2>9. Secure the Bootstrap Administrator<\/h2>\n<p>Before public access is enabled:<\/p>\n<ol>\n<li>Open the private Guacamole URL.<\/li>\n<li>Sign in with the initial bootstrap administrator.<\/li>\n<li>Enrol TOTP.<\/li>\n<li>Replace the initial password immediately.<\/li>\n<li>Create a separately named permanent administrator.<\/li>\n<li>Grant only the required system administration permissions.<\/li>\n<li>Test the replacement account in a separate browser session.<\/li>\n<li>Enrol TOTP for the replacement account.<\/li>\n<li>Confirm it can administer users and connections.<\/li>\n<li>Disable the bootstrap administrator.<\/li>\n<li>Confirm the disabled account can no longer authenticate.<\/li>\n<\/ol>\n<p>Do not record passwords or TOTP seeds in implementation notes.<\/p>\n<h2>10. Completion Checks<\/h2>\n<p>Part 1 is complete when:<\/p>\n<ul>\n<li>the LXD container is unprivileged and resource-limited;<\/li>\n<li>Docker uses an overlay-based storage driver;<\/li>\n<li>PostgreSQL and <code>guacd<\/code> are not published;<\/li>\n<li>the private Guacamole listener returns HTTP 200;<\/li>\n<li>TOTP authentication is active;<\/li>\n<li>the bootstrap administrator is disabled;<\/li>\n<li>PostgreSQL data is persistent;<\/li>\n<li>FreeRDP certificate trust is persistent;<\/li>\n<li>the internal and remote-access networks are distinct;<\/li>\n<li>no VM connection has been created before the Part 3 allowlist.<\/li>\n<\/ul>\n<h2>Next in the Series<\/h2>\n<p>Part 2 adds public DNS, a dedicated TLS certificate, Nginx WebSocket proxying,<br \/>\nreal-client address handling, and restriction of the private web listener to<br \/>\nthe reverse proxy.<\/p>\n<p>The gateway remains private at the end of this article. That provides a clean<br \/>\ncheckpoint for taking a container snapshot and testing recovery before an<br \/>\ninternet-facing endpoint exists.<\/p>\n<p><strong><a href=\"\/index.php\/apache-guacamole-part-2-publishing-the-gateway-through-nginx\/\">Continue Reading Part 2: Publishing the Gateway through Nginx<\/a><\/strong><\/p>\n<h2>References<\/h2>\n<ul>\n<li><a href=\"https:\/\/guacamole.apache.org\/doc\/gug\/guacamole-docker.html\">Apache Guacamole: Installing with Docker<\/a><\/li>\n<li><a href=\"https:\/\/guacamole.apache.org\/doc\/gug\/configuring-guacamole.html\">Apache Guacamole: Configuring Guacamole<\/a><\/li>\n<li><a href=\"https:\/\/docs.docker.com\/engine\/install\/ubuntu\/\">Docker: Install Docker Engine on Ubuntu<\/a><\/li>\n<li><a href=\"https:\/\/docs.docker.com\/reference\/compose-file\/networks\/\">Docker Compose network reference<\/a><\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>Build an isolated Apache Guacamole gateway with Ubuntu 24.04, LXD, Docker Compose, PostgreSQL, TOTP, and controlled application networks.<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"saved_in_kubio":false,"footnotes":""},"categories":[12,10],"tags":[],"class_list":["post-195","post","type-post","status-publish","format-standard","hentry","category-containers","category-server-builds"],"_links":{"self":[{"href":"https:\/\/www.miniamju.com\/index.php\/wp-json\/wp\/v2\/posts\/195","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.miniamju.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.miniamju.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.miniamju.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.miniamju.com\/index.php\/wp-json\/wp\/v2\/comments?post=195"}],"version-history":[{"count":0,"href":"https:\/\/www.miniamju.com\/index.php\/wp-json\/wp\/v2\/posts\/195\/revisions"}],"wp:attachment":[{"href":"https:\/\/www.miniamju.com\/index.php\/wp-json\/wp\/v2\/media?parent=195"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.miniamju.com\/index.php\/wp-json\/wp\/v2\/categories?post=195"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.miniamju.com\/index.php\/wp-json\/wp\/v2\/tags?post=195"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}