Apache Guacamole Part 2: Publishing the Gateway through Nginx

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;
  • Upgrade and Connection headers 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:

  1. the Guacamole username and password;
  2. the TOTP challenge;
  3. 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

References

Tags:

Comments are closed

Latest Comments