{"id":196,"date":"2026-07-28T06:16:47","date_gmt":"2026-07-28T06:16:47","guid":{"rendered":"https:\/\/www.miniamju.com\/index.php\/apache-guacamole-part-2-publishing-the-gateway-through-nginx\/"},"modified":"2026-07-28T06:16:47","modified_gmt":"2026-07-28T06:16:47","slug":"apache-guacamole-part-2-publishing-the-gateway-through-nginx","status":"publish","type":"post","link":"https:\/\/www.miniamju.com\/index.php\/apache-guacamole-part-2-publishing-the-gateway-through-nginx\/","title":{"rendered":"Apache Guacamole Part 2: Publishing the Gateway through Nginx"},"content":{"rendered":"<p><em>Part 2 of 3: public DNS, TLS termination, WebSocket proxying, and backend<br \/>\nisolation.<\/em><\/p>\n<p><a href=\"\/index.php\/apache-guacamole-part-1-building-the-remote-access-container\/\">Part 1<\/a><br \/>\nbuilt a private Guacamole gateway with PostgreSQL authentication, TOTP, and a<br \/>\nLAN-bound HTTP listener. This part publishes that listener through an existing<br \/>\nNginx reverse proxy without making the Docker backend directly available to<br \/>\nthe rest of the network.<\/p>\n<p>The finished request path is:<\/p>\n<pre><code class=\"language-text\">browser\n   |\nHTTPS 443\n   v\nNginx reverse proxy\n   |\nprivate HTTP 8080\n   v\nGuacamole container\n<\/code><\/pre>\n<p>Nginx terminates TLS, supports Guacamole&#39;s long-lived WebSocket tunnel, and<br \/>\nforwards the original client address. A firewall rule then permits only Nginx<br \/>\nto reach the private Guacamole listener.<\/p>\n<p>All hostnames and addresses below are generic examples. Replace them with<br \/>\nvalues suitable for the local environment.<\/p>\n<blockquote>\n<p><strong>Why not expose port 8080 directly?<\/strong> The application listener is plain HTTP<br \/>\nand does not need to be reachable by users. Restricting it to the reverse<br \/>\nproxy creates one controlled public entry point for TLS, security headers,<br \/>\nlogging, certificate renewal, and later framing policy.<\/p>\n<\/blockquote>\n<h2>Environment Used in This Guide<\/h2>\n<table>\n<thead>\n<tr>\n<th>Item<\/th>\n<th>Example<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Guacamole version<\/td>\n<td>1.6.0<\/td>\n<\/tr>\n<tr>\n<td>Guacamole backend<\/td>\n<td><code>10.20.30.223:8080<\/code><\/td>\n<\/tr>\n<tr>\n<td>Nginx reverse proxy<\/td>\n<td><code>10.20.30.220<\/code><\/td>\n<\/tr>\n<tr>\n<td>Public hostname<\/td>\n<td><code>remote.example.com<\/code><\/td>\n<\/tr>\n<tr>\n<td>Parent website allowed to frame Guacamole<\/td>\n<td><code>https:\/\/www.example.com<\/code><\/td>\n<\/tr>\n<tr>\n<td>Certificate authority client<\/td>\n<td>Certbot<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>Before continuing, confirm that:<\/p>\n<ul>\n<li>the Part 1 containers are healthy;<\/li>\n<li>the private Guacamole listener returns HTTP 200;<\/li>\n<li>the replacement administrator and TOTP login have been tested;<\/li>\n<li>public DNS can be pointed to the Nginx edge;<\/li>\n<li>TCP 80 and 443 already reach Nginx from the internet.<\/li>\n<\/ul>\n<h2>1. Make Guacamole Reverse-Proxy Aware<\/h2>\n<p>Guacamole runs in Tomcat. Without Tomcat&#39;s Remote IP Valve, application logs,<br \/>\nlogin banning, and audit history can record the Nginx address instead of the<br \/>\nreal browser address. The valve also tells Tomcat that the original request<br \/>\nused HTTPS.<\/p>\n<p>In the <code>guacamole<\/code> service within <code>\/opt\/guacamole\/compose.yaml<\/code>, confirm these<br \/>\nenvironment values are present:<\/p>\n<pre><code class=\"language-yaml\">      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<\/code><\/pre>\n<p>Do not override <code>REMOTE_IP_VALVE_INTERNAL_PROXIES<\/code> unless the actual Docker<br \/>\nnetwork path has first been inspected. Tomcat&#39;s default internal-proxy pattern<br \/>\nalready covers the usual private Docker bridge ranges.<\/p>\n<p>Validate Compose and recreate only the web application:<\/p>\n<pre><code class=\"language-bash\">cd \/opt\/guacamole\n\ndocker compose config\ndocker compose up -d --force-recreate guacamole\ndocker 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\n<\/code><\/pre>\n<p>Inspect the generated Tomcat configuration:<\/p>\n<pre><code class=\"language-bash\">docker compose exec -T guacamole sh -c &#39;\ngrep -n &quot;RemoteIpValve&quot; \/tmp\/catalina-base.*\/conf\/server.xml\n&#39;\n<\/code><\/pre>\n<p>Expected attributes include:<\/p>\n<pre><code class=\"language-text\">remoteIpHeader=&quot;x-forwarded-for&quot;\nprotocolHeader=&quot;x-forwarded-proto&quot;\nprotocolHeaderHttpsValue=&quot;https&quot;\nhttpServerPort=&quot;80&quot;\nhttpsServerPort=&quot;443&quot;\n<\/code><\/pre>\n<p>Check startup logs and the private listener:<\/p>\n<pre><code class=\"language-bash\">docker compose logs --since=3m guacamole |\n  grep -iE &#39;error|exception|fatal&#39; ||\n  echo &quot;No Guacamole startup errors&quot;\n\ncurl -sS -o \/dev\/null -w &#39;HTTP %{http_code}\\n&#39; \\\n  http:\/\/10.20.30.223:8080\/\n<\/code><\/pre>\n<p>Expected:<\/p>\n<pre><code class=\"language-text\">No Guacamole startup errors\nHTTP 200\n<\/code><\/pre>\n<h2>2. Create Public DNS<\/h2>\n<p>Create an <code>A<\/code> or <code>AAAA<\/code> record for the chosen Guacamole hostname and point it<br \/>\nto the public address that forwards TCP 80 and 443 to Nginx.<\/p>\n<p>From a system using public DNS:<\/p>\n<pre><code class=\"language-bash\">getent ahostsv4 remote.example.com\n<\/code><\/pre>\n<p>Confirm the result is the intended public edge address. DNS does not need to<br \/>\npoint directly to the Guacamole container.<\/p>\n<h2>3. Prepare the ACME Webroot<\/h2>\n<p>On the Nginx reverse proxy:<\/p>\n<pre><code class=\"language-bash\">install -d -o www-data -g www-data -m 0755 \\\n  \/var\/www\/letsencrypt\/.well-known\/acme-challenge\n<\/code><\/pre>\n<p>Create an HTTP-only site before requesting the certificate:<\/p>\n<pre><code class=\"language-bash\">cat &gt; \/etc\/nginx\/sites-available\/remote.example.com &lt;&lt;&#39;EOF&#39;\nserver {\n    listen 80;\n    listen [::]:80;\n    server_name remote.example.com;\n\n    location ^~ \/.well-known\/acme-challenge\/ {\n        root \/var\/www\/letsencrypt;\n        default_type text\/plain;\n        try_files $uri =404;\n    }\n\n    location \/ {\n        return 301 https:\/\/$host$request_uri;\n    }\n}\nEOF\n\nln -sfn \/etc\/nginx\/sites-available\/remote.example.com \\\n  \/etc\/nginx\/sites-enabled\/remote.example.com\n\nnginx -t\nsystemctl reload nginx\n<\/code><\/pre>\n<p>Expected:<\/p>\n<pre><code class=\"language-text\">syntax is ok\ntest is successful\n<\/code><\/pre>\n<p>The HTTP redirect will not yet produce a working HTTPS page because the<br \/>\ncertificate and TLS server block have not been created. The ACME challenge<br \/>\npath remains available over HTTP.<\/p>\n<h2>4. Obtain a Dedicated TLS Certificate<\/h2>\n<p>A separate certificate lineage keeps Guacamole certificate changes independent<br \/>\nfrom unrelated services.<\/p>\n<p>Install Certbot if it is not already present:<\/p>\n<pre><code class=\"language-bash\">apt update\napt install -y certbot\n<\/code><\/pre>\n<p>Request the certificate, replacing the example email address:<\/p>\n<pre><code class=\"language-bash\">certbot certonly \\\n  --webroot \\\n  --webroot-path \/var\/www\/letsencrypt \\\n  --domain remote.example.com \\\n  --email administrator@example.com \\\n  --agree-tos \\\n  --no-eff-email \\\n  --key-type ecdsa\n<\/code><\/pre>\n<p>Expected certificate paths:<\/p>\n<pre><code class=\"language-text\">\/etc\/letsencrypt\/live\/remote.example.com\/fullchain.pem\n\/etc\/letsencrypt\/live\/remote.example.com\/privkey.pem\n<\/code><\/pre>\n<p>Inspect the result:<\/p>\n<pre><code class=\"language-bash\">certbot certificates\n<\/code><\/pre>\n<p>Do not copy the private key into the Guacamole container. TLS terminates only<br \/>\nat Nginx.<\/p>\n<h2>5. Configure the Nginx Reverse Proxy<\/h2>\n<p>Replace the temporary HTTP-only site with the complete configuration:<\/p>\n<pre><code class=\"language-bash\">cat &gt; \/etc\/nginx\/sites-available\/remote.example.com &lt;&lt;&#39;EOF&#39;\nserver {\n    listen 80;\n    listen [::]:80;\n    server_name remote.example.com;\n\n    location ^~ \/.well-known\/acme-challenge\/ {\n        root \/var\/www\/letsencrypt;\n        default_type text\/plain;\n        try_files $uri =404;\n    }\n\n    location \/ {\n        return 301 https:\/\/$host$request_uri;\n    }\n}\n\nserver {\n    listen 443 ssl;\n    listen [::]:443 ssl;\n    server_name remote.example.com;\n\n    ssl_certificate\n        \/etc\/letsencrypt\/live\/remote.example.com\/fullchain.pem;\n    ssl_certificate_key\n        \/etc\/letsencrypt\/live\/remote.example.com\/privkey.pem;\n\n    include \/etc\/letsencrypt\/options-ssl-nginx.conf;\n    ssl_dhparam \/etc\/letsencrypt\/ssl-dhparams.pem;\n\n    location \/ {\n        proxy_pass http:\/\/10.20.30.223:8080;\n        proxy_http_version 1.1;\n\n        proxy_set_header Host $http_host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header X-Forwarded-Port $server_port;\n\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection &quot;upgrade&quot;;\n\n        proxy_buffering off;\n        proxy_request_buffering off;\n        proxy_read_timeout 3600s;\n        proxy_send_timeout 3600s;\n    }\n\n    add_header Strict-Transport-Security &quot;max-age=15552000&quot; always;\n    add_header X-Content-Type-Options &quot;nosniff&quot; always;\n    add_header Referrer-Policy &quot;strict-origin-when-cross-origin&quot; always;\n    add_header Content-Security-Policy\n        &quot;frame-ancestors &#39;self&#39; https:\/\/www.example.com&quot; always;\n}\nEOF\n\nnginx -t\nsystemctl reload nginx\n<\/code><\/pre>\n<p>The important Guacamole-specific settings are:<\/p>\n<ul>\n<li>HTTP\/1.1 for WebSocket upgrades;<\/li>\n<li><code>Upgrade<\/code> and <code>Connection<\/code> headers for the interactive tunnel;<\/li>\n<li>disabled response and request buffering;<\/li>\n<li>long read and send timeouts;<\/li>\n<li>forwarded host, client address, protocol, and port.<\/li>\n<\/ul>\n<p>The <code>frame-ancestors<\/code> policy allows only Guacamole itself and the nominated<br \/>\nparent website to embed the interface. Remove the external origin if embedding<br \/>\nis not required. Do not use <code>*<\/code>.<\/p>\n<h2>6. Validate the Public Endpoint<\/h2>\n<p>From the Nginx host:<\/p>\n<pre><code class=\"language-bash\">curl -sS -o \/dev\/null \\\n  -w &#39;HTTP %{http_code} redirect=%{redirect_url}\\n&#39; \\\n  http:\/\/remote.example.com\/\n\ncurl -sS -D - -o \/dev\/null \\\n  https:\/\/remote.example.com\/\n<\/code><\/pre>\n<p>Expected:<\/p>\n<pre><code class=\"language-text\">HTTP 301 redirect=https:\/\/remote.example.com\/\nHTTP\/1.1 200\n<\/code><\/pre>\n<p>The HTTPS response should also contain:<\/p>\n<pre><code class=\"language-text\">Strict-Transport-Security: max-age=15552000\nX-Content-Type-Options: nosniff\nReferrer-Policy: strict-origin-when-cross-origin\nContent-Security-Policy: frame-ancestors &#39;self&#39; https:\/\/www.example.com\n<\/code><\/pre>\n<p>At this stage the browser should show the Guacamole login page through HTTPS.<br \/>\nThe first actual remote-display WebSocket tunnel cannot be validated until<br \/>\nPart 3 creates a connection.<\/p>\n<h2>7. Test the Backend Restriction Temporarily<\/h2>\n<p>Publishing a Docker port creates forwarding rules outside a normal host<br \/>\n<code>INPUT<\/code> chain. Place the restriction in Docker&#39;s <code>DOCKER-USER<\/code> chain so it is<br \/>\nevaluated before Docker accepts forwarded traffic.<\/p>\n<p>On the Guacamole container, add temporary rules:<\/p>\n<pre><code class=\"language-bash\">iptables -I DOCKER-USER 1 \\\n  -i eth0 -p tcp -s 10.20.30.220 --dport 8080 \\\n  -j ACCEPT\n\niptables -I DOCKER-USER 2 \\\n  -i eth0 -p tcp --dport 8080 \\\n  -j DROP\n\niptables -nvL DOCKER-USER --line-numbers\n<\/code><\/pre>\n<p>Expected order:<\/p>\n<pre><code class=\"language-text\">1 ACCEPT tcp from 10.20.30.220 to destination port 8080\n2 DROP   tcp from all sources to destination port 8080\n<\/code><\/pre>\n<p>From the Nginx reverse proxy:<\/p>\n<pre><code class=\"language-bash\">curl -sS -o \/dev\/null -w &#39;HTTP %{http_code}\\n&#39; \\\n  http:\/\/10.20.30.223:8080\/\n<\/code><\/pre>\n<p>Expected:<\/p>\n<pre><code class=\"language-text\">HTTP 200\n<\/code><\/pre>\n<p>From another LAN system:<\/p>\n<pre><code class=\"language-bash\">curl --connect-timeout 5 -sS -o \/dev\/null \\\n  -w &#39;HTTP %{http_code}\\n&#39; \\\n  http:\/\/10.20.30.223:8080\/\n<\/code><\/pre>\n<p>Expected:<\/p>\n<pre><code class=\"language-text\">connection timeout\nHTTP 000\n<\/code><\/pre>\n<p>Confirm that the intended public route still works:<\/p>\n<pre><code class=\"language-bash\">curl -sS -o \/dev\/null -w &#39;HTTP %{http_code}\\n&#39; \\\n  https:\/\/remote.example.com\/\n<\/code><\/pre>\n<p>Expected:<\/p>\n<pre><code class=\"language-text\">HTTP 200\n<\/code><\/pre>\n<h2>8. Make the Backend Restriction Persistent<\/h2>\n<p>Docker recreates its own firewall rules when it restarts. A small systemd<br \/>\nservice can recreate only the locally owned Guacamole policy afterward.<\/p>\n<p>Create the script:<\/p>\n<pre><code class=\"language-bash\">cat &gt; \/usr\/local\/sbin\/guacamole-firewall &lt;&lt;&#39;EOF&#39;\n#!\/bin\/sh\nset -eu\n\nPATH=\/usr\/sbin:\/usr\/bin:\/sbin:\/bin\nPARENT_CHAIN=&quot;DOCKER-USER&quot;\nWEB_CHAIN=&quot;GUAC-WEB&quot;\nPROXY_IP=&quot;10.20.30.220&quot;\nBACKEND_PORT=&quot;8080&quot;\nLAN_INTERFACE=&quot;eth0&quot;\n\nwait_for_docker_chain() {\n    attempts=0\n\n    until iptables -nL &quot;$PARENT_CHAIN&quot; &gt;\/dev\/null 2&gt;&amp;1; do\n        attempts=$((attempts + 1))\n\n        if [ &quot;$attempts&quot; -ge 30 ]; then\n            echo &quot;Timed out waiting for $PARENT_CHAIN&quot; &gt;&amp;2\n            exit 1\n        fi\n\n        sleep 1\n    done\n}\n\nstart_rules() {\n    wait_for_docker_chain\n\n    iptables -N &quot;$WEB_CHAIN&quot; 2&gt;\/dev\/null || true\n    iptables -F &quot;$WEB_CHAIN&quot;\n\n    iptables -A &quot;$WEB_CHAIN&quot; \\\n        -p tcp -s &quot;$PROXY_IP&quot; --dport &quot;$BACKEND_PORT&quot; \\\n        -j ACCEPT\n\n    iptables -A &quot;$WEB_CHAIN&quot; \\\n        -p tcp --dport &quot;$BACKEND_PORT&quot; \\\n        -j DROP\n\n    if ! iptables -C &quot;$PARENT_CHAIN&quot; \\\n        -i &quot;$LAN_INTERFACE&quot; -p tcp --dport &quot;$BACKEND_PORT&quot; \\\n        -j &quot;$WEB_CHAIN&quot; 2&gt;\/dev\/null; then\n\n        iptables -I &quot;$PARENT_CHAIN&quot; 1 \\\n            -i &quot;$LAN_INTERFACE&quot; -p tcp --dport &quot;$BACKEND_PORT&quot; \\\n            -j &quot;$WEB_CHAIN&quot;\n    fi\n\n    while iptables -C &quot;$PARENT_CHAIN&quot; \\\n        -i &quot;$LAN_INTERFACE&quot; -p tcp -s &quot;$PROXY_IP&quot; \\\n        --dport &quot;$BACKEND_PORT&quot; -j ACCEPT 2&gt;\/dev\/null; do\n\n        iptables -D &quot;$PARENT_CHAIN&quot; \\\n            -i &quot;$LAN_INTERFACE&quot; -p tcp -s &quot;$PROXY_IP&quot; \\\n            --dport &quot;$BACKEND_PORT&quot; -j ACCEPT\n    done\n\n    while iptables -C &quot;$PARENT_CHAIN&quot; \\\n        -i &quot;$LAN_INTERFACE&quot; -p tcp --dport &quot;$BACKEND_PORT&quot; \\\n        -j DROP 2&gt;\/dev\/null; do\n\n        iptables -D &quot;$PARENT_CHAIN&quot; \\\n            -i &quot;$LAN_INTERFACE&quot; -p tcp --dport &quot;$BACKEND_PORT&quot; \\\n            -j DROP\n    done\n}\n\nstop_rules() {\n    if iptables -nL &quot;$PARENT_CHAIN&quot; &gt;\/dev\/null 2&gt;&amp;1; then\n        while iptables -C &quot;$PARENT_CHAIN&quot; \\\n            -i &quot;$LAN_INTERFACE&quot; -p tcp --dport &quot;$BACKEND_PORT&quot; \\\n            -j &quot;$WEB_CHAIN&quot; 2&gt;\/dev\/null; do\n\n            iptables -D &quot;$PARENT_CHAIN&quot; \\\n                -i &quot;$LAN_INTERFACE&quot; -p tcp --dport &quot;$BACKEND_PORT&quot; \\\n                -j &quot;$WEB_CHAIN&quot;\n        done\n    fi\n\n    if iptables -nL &quot;$WEB_CHAIN&quot; &gt;\/dev\/null 2&gt;&amp;1; then\n        iptables -F &quot;$WEB_CHAIN&quot;\n        iptables -X &quot;$WEB_CHAIN&quot;\n    fi\n}\n\ncase &quot;${1:-}&quot; in\n    start)\n        start_rules\n        ;;\n    stop)\n        stop_rules\n        ;;\n    *)\n        echo &quot;Usage: $0 {start|stop}&quot; &gt;&amp;2\n        exit 2\n        ;;\nesac\nEOF\n\nchmod 0750 \/usr\/local\/sbin\/guacamole-firewall\n<\/code><\/pre>\n<p>Create the systemd unit:<\/p>\n<pre><code class=\"language-bash\">cat &gt; \/etc\/systemd\/system\/guacamole-firewall.service &lt;&lt;&#39;EOF&#39;\n[Unit]\nDescription=Restrict Guacamole backend to the Nginx reverse proxy\nRequires=docker.service\nAfter=docker.service\nPartOf=docker.service\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=\/usr\/local\/sbin\/guacamole-firewall start\nExecStop=\/usr\/local\/sbin\/guacamole-firewall stop\nTimeoutStartSec=45\nTimeoutStopSec=15\n\n[Install]\nWantedBy=multi-user.target\nEOF\n<\/code><\/pre>\n<p>Enable and inspect it:<\/p>\n<pre><code class=\"language-bash\">systemctl daemon-reload\nsystemctl enable --now guacamole-firewall.service\n\nsystemctl is-enabled guacamole-firewall.service\nsystemctl is-active guacamole-firewall.service\nsystemctl status --no-pager guacamole-firewall.service\n\niptables -nvL DOCKER-USER --line-numbers\niptables -nvL GUAC-WEB --line-numbers\n<\/code><\/pre>\n<p>Expected:<\/p>\n<pre><code class=\"language-text\">enabled\nactive\nDOCKER-USER rule 1 delegates eth0 TCP\/8080 to GUAC-WEB\nGUAC-WEB rule 1 accepts 10.20.30.220\nGUAC-WEB rule 2 drops all other TCP\/8080 sources\n<\/code><\/pre>\n<p>Restart Docker to test persistence:<\/p>\n<pre><code class=\"language-bash\">systemctl restart docker\n\nsystemctl is-active docker\nsystemctl is-active guacamole-firewall.service\n\ncd \/opt\/guacamole\ndocker compose ps\n\niptables -nvL DOCKER-USER --line-numbers\niptables -nvL GUAC-WEB --line-numbers\n<\/code><\/pre>\n<p>Repeat the three access tests:<\/p>\n<pre><code class=\"language-text\">Nginx -&gt; private backend: HTTP 200\nanother LAN host -&gt; private backend: timeout \/ HTTP 000\npublic HTTPS -&gt; Nginx -&gt; private backend: HTTP 200\n<\/code><\/pre>\n<h2>9. Validate Authentication and Client Addressing<\/h2>\n<p>Open <code>https:\/\/remote.example.com\/<\/code> in a browser and complete:<\/p>\n<ol>\n<li>the Guacamole username and password;<\/li>\n<li>the TOTP challenge;<\/li>\n<li>loading the authenticated dashboard.<\/li>\n<\/ol>\n<p>Check the application logs during the test:<\/p>\n<pre><code class=\"language-bash\">cd \/opt\/guacamole\n\ndocker compose logs --since=10m guacamole |\n  grep -iE &#39;error|exception|fatal&#39; ||\n  echo &quot;No Guacamole authentication errors&quot;\n<\/code><\/pre>\n<p>The Guacamole user-history entry should contain the actual browser address, not<br \/>\nthe Nginx private address. Avoid publishing that address or the administrator<br \/>\nusername in screenshots or articles.<\/p>\n<p>Also test certificate renewal:<\/p>\n<pre><code class=\"language-bash\">systemctl status --no-pager certbot.timer\ncertbot renew --dry-run\n<\/code><\/pre>\n<p>Expected:<\/p>\n<pre><code class=\"language-text\">the renewal timer is active\nthe dry run completes successfully\n<\/code><\/pre>\n<h2>Completion Checks<\/h2>\n<p>Part 2 is complete when:<\/p>\n<ul>\n<li>HTTP redirects to the dedicated HTTPS hostname;<\/li>\n<li>the public endpoint returns the Guacamole login page;<\/li>\n<li>the certificate is valid and renewal has been tested;<\/li>\n<li>Nginx forwards WebSocket upgrade headers and disables buffering;<\/li>\n<li>Guacamole records the original client address;<\/li>\n<li>password and TOTP authentication work through the proxy;<\/li>\n<li>only Nginx can reach the private backend listener;<\/li>\n<li>the backend restriction survives a Docker restart.<\/li>\n<\/ul>\n<p>The first live remote-display tunnel is intentionally deferred until Part 3.<br \/>\nAn authenticated dashboard proves the HTTPS login path, but only an actual<br \/>\nconnection exercises Guacamole&#39;s long-lived WebSocket tunnel.<\/p>\n<p><strong><a href=\"\/index.php\/apache-guacamole-part-3-connecting-private-virtual-machines\/\">Continue Reading Part 3: Connecting Private Virtual Machines<\/a><\/strong><\/p>\n<h2>References<\/h2>\n<ul>\n<li><a href=\"https:\/\/guacamole.apache.org\/doc\/gug\/reverse-proxy.html\">Apache Guacamole: Using a reverse proxy for SSL termination<\/a><\/li>\n<li><a href=\"https:\/\/docs.docker.com\/engine\/network\/firewall-iptables\/\">Docker: Docker with iptables<\/a><\/li>\n<li><a href=\"https:\/\/nginx.org\/en\/docs\/http\/websocket.html\">NGINX: WebSocket proxying<\/a><\/li>\n<li><a href=\"https:\/\/eff-certbot.readthedocs.io\/en\/stable\/using.html\">Certbot user guide<\/a><\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>Publish Apache Guacamole through Nginx with TLS, WebSocket proxying, real-client addressing, and a backend restricted to the reverse proxy.<\/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,14],"tags":[],"class_list":["post-196","post","type-post","status-publish","format-standard","hentry","category-containers","category-wordpress-web"],"_links":{"self":[{"href":"https:\/\/www.miniamju.com\/index.php\/wp-json\/wp\/v2\/posts\/196","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=196"}],"version-history":[{"count":0,"href":"https:\/\/www.miniamju.com\/index.php\/wp-json\/wp\/v2\/posts\/196\/revisions"}],"wp:attachment":[{"href":"https:\/\/www.miniamju.com\/index.php\/wp-json\/wp\/v2\/media?parent=196"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.miniamju.com\/index.php\/wp-json\/wp\/v2\/categories?post=196"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.miniamju.com\/index.php\/wp-json\/wp\/v2\/tags?post=196"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}