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.