Nginx Reverse Proxy with Wildcard TLS

July 29, 2026

A reverse proxy sits between the internet and your backend services, terminating TLS and forwarding requests to the appropriate server. With a wildcard certificate, every subdomain — mail.example.com, cloud.example.com, analytics.example.com — is covered by a single certificate. This post covers installing nginx, obtaining wildcard certificates via DNS-01 challenge with Let's Encrypt, and configuring the reverse proxy with proper headers, WebSocket support, and security defaults.

Installing nginx

On Gentoo

emerge -av www-servers/nginx

On Debian

apt install -y nginx

On Alpine

apk add nginx nginx-openrc

Starting the Service

On Gentoo

rc-update add nginx default
rc-service nginx start

On Debian

systemctl enable nginx
systemctl start nginx

On Alpine

rc-update add nginx default
rc-service nginx start

Verify nginx is listening:

ss -lnt sport = :80

Obtaining Wildcard Certificates

Wildcard certificates (*.example.com) require DNS-01 validation — you prove domain ownership by creating a TXT record in your DNS zone. HTTP-01 validation can't issue wildcards because it only proves control of a single hostname.

Installing lego

lego is a standalone ACME client that supports DNS-01 challenges with dozens of DNS providers. It's simpler than certbot for automated wildcard certificate management.

On Gentoo

emerge -av app-crypt/lego

On Debian

apt install -y lego

If lego isn't in your distribution's repositories, download the binary from the project's releases:

curl -L -o /usr/local/bin/lego https://github.com/go-acme/lego/releases/latest/download/lego_linux_amd64
chmod +x /usr/local/bin/lego

On Alpine

apk add lego

DNS Provider Configuration

This example uses Hetzner DNS, but lego supports Cloudflare, Route53, DigitalOcean, and many others. Each provider requires an API token for automated DNS record creation.

For Hetzner, generate an API token in the Hetzner DNS Console and export it:

export HETZNER_API_TOKEN="your-dns-api-token"

Requesting the Certificate

Create the directories lego needs:

mkdir -p /etc/lego
mkdir -p /etc/nginx/ssl

Request a wildcard certificate that covers both the base domain and all subdomains:

HETZNER_API_TOKEN="your-dns-api-token" lego \
  --accept-tos \
  --email=admin@example.com \
  --dns=hetzner \
  --server=https://acme-v02.api.letsencrypt.org/directory \
  --path=/etc/lego \
  --domains="*.example.com" \
  --domains="example.com" \
  run

For multiple domains (e.g., if you run example.com and example.org):

HETZNER_API_TOKEN="your-dns-api-token" lego \
  --accept-tos \
  --email=admin@example.com \
  --dns=hetzner \
  --server=https://acme-v02.api.letsencrypt.org/directory \
  --path=/etc/lego \
  --domains="*.example.com" \
  --domains="example.com" \
  --domains="*.example.org" \
  --domains="example.org" \
  run

Lego creates the TXT record, waits for DNS propagation, validates with Let's Encrypt, and stores the certificate files in /etc/lego/certificates/.

Testing first: Use the staging server to avoid rate limits while you're testing:

--server=https://acme-staging-v02.api.letsencrypt.org/directory

Switch to the production server once everything works.

Linking Certificates to nginx

Create symlinks from the lego certificate directory to where nginx expects them:

ln -sf /etc/lego/certificates/_.example.com.crt /etc/nginx/ssl/wildcard.crt
ln -sf /etc/lego/certificates/_.example.com.key /etc/nginx/ssl/wildcard.key

Using symlinks means certificate renewals are picked up automatically after an nginx reload — no need to copy files.

Configuring the nginx Reverse Proxy

Directory Structure

nginx configuration paths differ across distributions:

Distribution Site configs Main config
Gentoo /etc/nginx/sites-enabled/ /etc/nginx/nginx.conf
Debian /etc/nginx/sites-enabled/ /etc/nginx/nginx.conf
Alpine /etc/nginx/http.d/ /etc/nginx/nginx.conf

On Alpine, site configs go in /etc/nginx/http.d/. On Gentoo and Debian, they go in /etc/nginx/sites-enabled/.

WebSocket Upgrade Map

Add a WebSocket upgrade map to the http block in /etc/nginx/nginx.conf. This enables WebSocket proxying for applications like Syncthing and chat services:

http {
    map $http_upgrade $connection_upgrade {
        default upgrade;
        ""      close;
    }

    # ... rest of http block
}

HTTP to HTTPS Redirect

Create a catch-all redirect that sends all HTTP traffic to HTTPS. On Alpine, write to /etc/nginx/http.d/00-http-redirect.conf. On Gentoo/Debian, write to /etc/nginx/sites-enabled/00-http-redirect.conf:

server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name _;

    return 301 https://$host$request_uri;
}

Default HTTPS Fallback

Create a default HTTPS server that rejects requests for unconfigured domains. This prevents nginx from serving the wrong site when a request hits an IP or unknown hostname:

server {
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;
    http2 on;
    server_name _;

    ssl_certificate /etc/nginx/ssl/wildcard.crt;
    ssl_certificate_key /etc/nginx/ssl/wildcard.key;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;

    return 444;
}

The return 444 is an nginx-specific code that closes the connection without sending a response. This is the correct behavior for requests to unconfigured domains — no content is leaked.

Reverse Proxy Site Configuration

For each backend service, create a server block. This example proxies cloud.example.com to a backend running on 192.168.1.10:80:

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name cloud.example.com;

    ssl_certificate /etc/nginx/ssl/wildcard.crt;
    ssl_certificate_key /etc/nginx/ssl/wildcard.key;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;

    location / {
        proxy_pass http://192.168.1.10:80;
        proxy_http_version 1.1;

        # Proxy headers
        proxy_set_header Host $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-Host $host;
        proxy_set_header X-Forwarded-Port $server_port;

        # WebSocket support
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;

        # Timeouts
        proxy_connect_timeout 60;
        proxy_send_timeout 60;
        proxy_read_timeout 60;

        # Buffering
        proxy_buffering on;
        proxy_buffer_size 4k;
        proxy_buffers 8 4k;

        # Max body size (for file uploads)
        client_max_body_size 512M;
    }
}

Proxy Header Decisions

Host $host — passes the original hostname to the backend. Without this, the backend sees the proxy's IP as the hostname, breaking virtual host routing on the backend.

X-Real-IP and X-Forwarded-For — the backend needs the client's real IP for logging, rate limiting, and access control. Without these headers, every request appears to come from the proxy server.

X-Forwarded-Proto $scheme — tells the backend whether the original request was HTTP or HTTPS. Applications use this to generate correct URLs and set secure cookie flags.

Upgrade and Connection — enables WebSocket proxying. The $connection_upgrade variable from the map block handles the upgrade negotiation.

client_max_body_size 512M — allows large file uploads. The default is 1MB, which is too small for file sync (Nextcloud) and mail attachments. Adjust based on your applications.

Adding Basic Authentication

For services that don't have their own authentication (monitoring dashboards, internal tools), add HTTP basic auth:

Install htpasswd:

# Gentoo
emerge -av app-admin/apache-tools

# Debian
apt install -y apache2-utils

# Alpine
apk add apache2-utils

Create a password file:

mkdir -p /etc/nginx/auth
htpasswd -c /etc/nginx/auth/internal.example.com.htpasswd admin

Add the auth directives to the location block:

location / {
    auth_basic "Restricted";
    auth_basic_user_file /etc/nginx/auth/internal.example.com.htpasswd;

    proxy_pass http://192.168.1.10:8080;
    # ... rest of proxy config
}

Set permissions on the htpasswd file:

chown root:nginx /etc/nginx/auth/internal.example.com.htpasswd
chmod 640 /etc/nginx/auth/internal.example.com.htpasswd

Removing the Default Site

Remove the default nginx site that ships with the package:

On Alpine

rm -f /etc/nginx/http.d/default.conf

On Debian

rm -f /etc/nginx/sites-enabled/default

On Gentoo

Gentoo typically doesn't ship a default site, but check:

ls /etc/nginx/sites-enabled/

Validating and Reloading

Always validate the configuration before reloading:

nginx -t

Expected output:

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Reload to apply changes (without dropping active connections):

# Gentoo/Alpine
rc-service nginx reload

# Debian
systemctl reload nginx

Use reload instead of restart — it gracefully loads the new configuration without interrupting existing connections.

Automatic Certificate Renewal

Let's Encrypt certificates expire after 90 days. Set up a cron job to renew automatically.

Create /usr/local/bin/proxy-cert-renew.sh:

#!/bin/sh
set -e

export HETZNER_API_TOKEN="your-dns-api-token"

echo "$(date): Starting certificate renewal check"

lego \
  --accept-tos \
  --email=admin@example.com \
  --dns=hetzner \
  --server=https://acme-v02.api.letsencrypt.org/directory \
  --path=/etc/lego \
  --domains="*.example.com" \
  --domains="example.com" \
  renew --days=30

RESULT=$?

if [ $RESULT -eq 0 ]; then
  echo "$(date): Certificate renewal check completed"
  if [ -f /var/run/nginx.pid ]; then
    nginx -s reload
    echo "$(date): Nginx reloaded"
  fi
else
  echo "$(date): Certificate renewal failed with exit code $RESULT"
fi

exit $RESULT

Set permissions and add the cron job:

chmod 750 /usr/local/bin/proxy-cert-renew.sh

crontab -e

Add:

30 3 * * * /usr/local/bin/proxy-cert-renew.sh >> /var/log/proxy-cert-renewal.log 2>&1

This runs daily at 3:30 AM. The --days=30 flag means lego only requests a new certificate if the current one expires within 30 days. Most runs exit immediately with no action.

Testing the Proxy

Verify TLS

curl -vI https://cloud.example.com 2>&1 | grep -E "subject|issuer|expire"

Confirm the certificate shows *.example.com as the subject and Let's Encrypt as the issuer.

Verify Proxy Headers

On the backend server, check the access log for the X-Forwarded-For header to confirm real client IPs are being passed through.

Test HTTP to HTTPS Redirect

curl -I http://cloud.example.com

Should return:

HTTP/1.1 301 Moved Permanently
Location: https://cloud.example.com/

Summary

After completing these steps:

  • nginx terminates TLS for all subdomains using a wildcard certificate
  • All HTTP traffic is redirected to HTTPS
  • Requests to unconfigured domains are silently dropped
  • Backend services receive correct client IPs and protocol information
  • WebSocket connections are proxied transparently
  • Certificates renew automatically via cron

Each new backend service only requires a single server block added to the nginx configuration. The wildcard certificate covers any subdomain without re-issuing.