Part 2 of 3: public DNS, TLS termination, WebSocket proxying, and backend
isolation.
Part 1
built a private Guacamole gateway with PostgreSQL authentication, TOTP, and a
LAN-bound HTTP listener. This part publishes that listener through an existing
Nginx reverse proxy without making the Docker backend directly available to
the rest of the network.
The finished request path is:
browser
|
HTTPS 443
v
Nginx reverse proxy
|
private HTTP 8080
v
Guacamole container
Nginx terminates TLS, supports Guacamole's long-lived WebSocket tunnel, and
forwards the original client address. A firewall rule then permits only Nginx
to reach the private Guacamole listener.
All hostnames and addresses below are generic examples. Replace them with
values suitable for the local environment.
Why not expose port 8080 directly? The application listener is plain HTTP
and does not need to be reachable by users. Restricting it to the reverse
proxy creates one controlled public entry point for TLS, security headers,
logging, certificate renewal, and later framing policy.
Environment Used in This Guide
| Item | Example |
|---|---|
| Guacamole version | 1.6.0 |
| Guacamole backend | 10.20.30.223:8080 |
| Nginx reverse proxy | 10.20.30.220 |
| Public hostname | remote.example.com |
| Parent website allowed to frame Guacamole | https://www.example.com |
| Certificate authority client | Certbot |
Before continuing, confirm that:
- the Part 1 containers are healthy;
- the private Guacamole listener returns HTTP 200;
- the replacement administrator and TOTP login have been tested;
- public DNS can be pointed to the Nginx edge;
- TCP 80 and 443 already reach Nginx from the internet.
1. Make Guacamole Reverse-Proxy Aware
Guacamole runs in Tomcat. Without Tomcat's Remote IP Valve, application logs,
login banning, and audit history can record the Nginx address instead of the
real browser address. The valve also tells Tomcat that the original request
used HTTPS.
In the guacamole service within /opt/guacamole/compose.yaml, confirm these
environment values are present:
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"
Do not override REMOTE_IP_VALVE_INTERNAL_PROXIES unless the actual Docker
network path has first been inspected. Tomcat's default internal-proxy pattern
already covers the usual private Docker bridge ranges.
Validate Compose and recreate only the web application:
cd /opt/guacamole
docker compose config
docker compose up -d --force-recreate guacamole
docker compose ps
Expected:
guacamole-postgres Up and healthy
guacamole-guacd Up
guacamole-web Up
Inspect the generated Tomcat configuration:
docker compose exec -T guacamole sh -c '
grep -n "RemoteIpValve" /tmp/catalina-base.*/conf/server.xml
'
Expected attributes include:
remoteIpHeader="x-forwarded-for"
protocolHeader="x-forwarded-proto"
protocolHeaderHttpsValue="https"
httpServerPort="80"
httpsServerPort="443"
Check startup logs and the private listener:
docker compose logs --since=3m guacamole |
grep -iE 'error|exception|fatal' ||
echo "No Guacamole startup errors"
curl -sS -o /dev/null -w 'HTTP %{http_code}\n' \
http://10.20.30.223:8080/
Expected:
No Guacamole startup errors
HTTP 200
2. Create Public DNS
Create an A or AAAA record for the chosen Guacamole hostname and point it
to the public address that forwards TCP 80 and 443 to Nginx.
From a system using public DNS:
getent ahostsv4 remote.example.com
Confirm the result is the intended public edge address. DNS does not need to
point directly to the Guacamole container.
3. Prepare the ACME Webroot
On the Nginx reverse proxy:
install -d -o www-data -g www-data -m 0755 \
/var/www/letsencrypt/.well-known/acme-challenge
Create an HTTP-only site before requesting the certificate:
cat > /etc/nginx/sites-available/remote.example.com <<'EOF'
server {
listen 80;
listen [::]:80;
server_name remote.example.com;
location ^~ /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
default_type text/plain;
try_files $uri =404;
}
location / {
return 301 https://$host$request_uri;
}
}
EOF
ln -sfn /etc/nginx/sites-available/remote.example.com \
/etc/nginx/sites-enabled/remote.example.com
nginx -t
systemctl reload nginx
Expected:
syntax is ok
test is successful
The HTTP redirect will not yet produce a working HTTPS page because the
certificate and TLS server block have not been created. The ACME challenge
path remains available over HTTP.
4. Obtain a Dedicated TLS Certificate
A separate certificate lineage keeps Guacamole certificate changes independent
from unrelated services.
Install Certbot if it is not already present:
apt update
apt install -y certbot
Request the certificate, replacing the example email address:
certbot certonly \
--webroot \
--webroot-path /var/www/letsencrypt \
--domain remote.example.com \
--email administrator@example.com \
--agree-tos \
--no-eff-email \
--key-type ecdsa
Expected certificate paths:
/etc/letsencrypt/live/remote.example.com/fullchain.pem
/etc/letsencrypt/live/remote.example.com/privkey.pem
Inspect the result:
certbot certificates
Do not copy the private key into the Guacamole container. TLS terminates only
at Nginx.
5. Configure the Nginx Reverse Proxy
Replace the temporary HTTP-only site with the complete configuration:
cat > /etc/nginx/sites-available/remote.example.com <<'EOF'
server {
listen 80;
listen [::]:80;
server_name remote.example.com;
location ^~ /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
default_type text/plain;
try_files $uri =404;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name remote.example.com;
ssl_certificate
/etc/letsencrypt/live/remote.example.com/fullchain.pem;
ssl_certificate_key
/etc/letsencrypt/live/remote.example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_pass http://10.20.30.223:8080;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
add_header Strict-Transport-Security "max-age=15552000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy
"frame-ancestors 'self' https://www.example.com" always;
}
EOF
nginx -t
systemctl reload nginx
The important Guacamole-specific settings are:
- HTTP/1.1 for WebSocket upgrades;
UpgradeandConnectionheaders for the interactive tunnel;- disabled response and request buffering;
- long read and send timeouts;
- forwarded host, client address, protocol, and port.
The frame-ancestors policy allows only Guacamole itself and the nominated
parent website to embed the interface. Remove the external origin if embedding
is not required. Do not use *.
6. Validate the Public Endpoint
From the Nginx host:
curl -sS -o /dev/null \
-w 'HTTP %{http_code} redirect=%{redirect_url}\n' \
http://remote.example.com/
curl -sS -D - -o /dev/null \
https://remote.example.com/
Expected:
HTTP 301 redirect=https://remote.example.com/
HTTP/1.1 200
The HTTPS response should also contain:
Strict-Transport-Security: max-age=15552000
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy: frame-ancestors 'self' https://www.example.com
At this stage the browser should show the Guacamole login page through HTTPS.
The first actual remote-display WebSocket tunnel cannot be validated until
Part 3 creates a connection.
7. Test the Backend Restriction Temporarily
Publishing a Docker port creates forwarding rules outside a normal host
INPUT chain. Place the restriction in Docker's DOCKER-USER chain so it is
evaluated before Docker accepts forwarded traffic.
On the Guacamole container, add temporary rules:
iptables -I DOCKER-USER 1 \
-i eth0 -p tcp -s 10.20.30.220 --dport 8080 \
-j ACCEPT
iptables -I DOCKER-USER 2 \
-i eth0 -p tcp --dport 8080 \
-j DROP
iptables -nvL DOCKER-USER --line-numbers
Expected order:
1 ACCEPT tcp from 10.20.30.220 to destination port 8080
2 DROP tcp from all sources to destination port 8080
From the Nginx reverse proxy:
curl -sS -o /dev/null -w 'HTTP %{http_code}\n' \
http://10.20.30.223:8080/
Expected:
HTTP 200
From another LAN system:
curl --connect-timeout 5 -sS -o /dev/null \
-w 'HTTP %{http_code}\n' \
http://10.20.30.223:8080/
Expected:
connection timeout
HTTP 000
Confirm that the intended public route still works:
curl -sS -o /dev/null -w 'HTTP %{http_code}\n' \
https://remote.example.com/
Expected:
HTTP 200
8. Make the Backend Restriction Persistent
Docker recreates its own firewall rules when it restarts. A small systemd
service can recreate only the locally owned Guacamole policy afterward.
Create the script:
cat > /usr/local/sbin/guacamole-firewall <<'EOF'
#!/bin/sh
set -eu
PATH=/usr/sbin:/usr/bin:/sbin:/bin
PARENT_CHAIN="DOCKER-USER"
WEB_CHAIN="GUAC-WEB"
PROXY_IP="10.20.30.220"
BACKEND_PORT="8080"
LAN_INTERFACE="eth0"
wait_for_docker_chain() {
attempts=0
until iptables -nL "$PARENT_CHAIN" >/dev/null 2>&1; do
attempts=$((attempts + 1))
if [ "$attempts" -ge 30 ]; then
echo "Timed out waiting for $PARENT_CHAIN" >&2
exit 1
fi
sleep 1
done
}
start_rules() {
wait_for_docker_chain
iptables -N "$WEB_CHAIN" 2>/dev/null || true
iptables -F "$WEB_CHAIN"
iptables -A "$WEB_CHAIN" \
-p tcp -s "$PROXY_IP" --dport "$BACKEND_PORT" \
-j ACCEPT
iptables -A "$WEB_CHAIN" \
-p tcp --dport "$BACKEND_PORT" \
-j DROP
if ! iptables -C "$PARENT_CHAIN" \
-i "$LAN_INTERFACE" -p tcp --dport "$BACKEND_PORT" \
-j "$WEB_CHAIN" 2>/dev/null; then
iptables -I "$PARENT_CHAIN" 1 \
-i "$LAN_INTERFACE" -p tcp --dport "$BACKEND_PORT" \
-j "$WEB_CHAIN"
fi
while iptables -C "$PARENT_CHAIN" \
-i "$LAN_INTERFACE" -p tcp -s "$PROXY_IP" \
--dport "$BACKEND_PORT" -j ACCEPT 2>/dev/null; do
iptables -D "$PARENT_CHAIN" \
-i "$LAN_INTERFACE" -p tcp -s "$PROXY_IP" \
--dport "$BACKEND_PORT" -j ACCEPT
done
while iptables -C "$PARENT_CHAIN" \
-i "$LAN_INTERFACE" -p tcp --dport "$BACKEND_PORT" \
-j DROP 2>/dev/null; do
iptables -D "$PARENT_CHAIN" \
-i "$LAN_INTERFACE" -p tcp --dport "$BACKEND_PORT" \
-j DROP
done
}
stop_rules() {
if iptables -nL "$PARENT_CHAIN" >/dev/null 2>&1; then
while iptables -C "$PARENT_CHAIN" \
-i "$LAN_INTERFACE" -p tcp --dport "$BACKEND_PORT" \
-j "$WEB_CHAIN" 2>/dev/null; do
iptables -D "$PARENT_CHAIN" \
-i "$LAN_INTERFACE" -p tcp --dport "$BACKEND_PORT" \
-j "$WEB_CHAIN"
done
fi
if iptables -nL "$WEB_CHAIN" >/dev/null 2>&1; then
iptables -F "$WEB_CHAIN"
iptables -X "$WEB_CHAIN"
fi
}
case "${1:-}" in
start)
start_rules
;;
stop)
stop_rules
;;
*)
echo "Usage: $0 {start|stop}" >&2
exit 2
;;
esac
EOF
chmod 0750 /usr/local/sbin/guacamole-firewall
Create the systemd unit:
cat > /etc/systemd/system/guacamole-firewall.service <<'EOF'
[Unit]
Description=Restrict Guacamole backend to the Nginx reverse proxy
Requires=docker.service
After=docker.service
PartOf=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/guacamole-firewall start
ExecStop=/usr/local/sbin/guacamole-firewall stop
TimeoutStartSec=45
TimeoutStopSec=15
[Install]
WantedBy=multi-user.target
EOF
Enable and inspect it:
systemctl daemon-reload
systemctl enable --now guacamole-firewall.service
systemctl is-enabled guacamole-firewall.service
systemctl is-active guacamole-firewall.service
systemctl status --no-pager guacamole-firewall.service
iptables -nvL DOCKER-USER --line-numbers
iptables -nvL GUAC-WEB --line-numbers
Expected:
enabled
active
DOCKER-USER rule 1 delegates eth0 TCP/8080 to GUAC-WEB
GUAC-WEB rule 1 accepts 10.20.30.220
GUAC-WEB rule 2 drops all other TCP/8080 sources
Restart Docker to test persistence:
systemctl restart docker
systemctl is-active docker
systemctl is-active guacamole-firewall.service
cd /opt/guacamole
docker compose ps
iptables -nvL DOCKER-USER --line-numbers
iptables -nvL GUAC-WEB --line-numbers
Repeat the three access tests:
Nginx -> private backend: HTTP 200
another LAN host -> private backend: timeout / HTTP 000
public HTTPS -> Nginx -> private backend: HTTP 200
9. Validate Authentication and Client Addressing
Open https://remote.example.com/ in a browser and complete:
- the Guacamole username and password;
- the TOTP challenge;
- loading the authenticated dashboard.
Check the application logs during the test:
cd /opt/guacamole
docker compose logs --since=10m guacamole |
grep -iE 'error|exception|fatal' ||
echo "No Guacamole authentication errors"
The Guacamole user-history entry should contain the actual browser address, not
the Nginx private address. Avoid publishing that address or the administrator
username in screenshots or articles.
Also test certificate renewal:
systemctl status --no-pager certbot.timer
certbot renew --dry-run
Expected:
the renewal timer is active
the dry run completes successfully
Completion Checks
Part 2 is complete when:
- HTTP redirects to the dedicated HTTPS hostname;
- the public endpoint returns the Guacamole login page;
- the certificate is valid and renewal has been tested;
- Nginx forwards WebSocket upgrade headers and disables buffering;
- Guacamole records the original client address;
- password and TOTP authentication work through the proxy;
- only Nginx can reach the private backend listener;
- the backend restriction survives a Docker restart.
The first live remote-display tunnel is intentionally deferred until Part 3.
An authenticated dashboard proves the HTTPS login path, but only an actual
connection exercises Guacamole's long-lived WebSocket tunnel.
Continue Reading Part 3: Connecting Private Virtual Machines
Comments are closed