Self-Hosting Services Behind a Reverse Proxy

September 12, 2026

This post documents the architecture for running multiple self-hosted services behind a single nginx reverse proxy with wildcard TLS certificates. It ties together the reverse proxy configuration, certificate management, and per-service setup from earlier posts into a cohesive reference.

Architecture

The setup uses a two-server model:

  • Proxy server — runs nginx, terminates TLS, and forwards requests to backend servers
  • Backend server(s) — run the actual applications ( Apache, Node.js, etc.) on HTTP

The proxy server holds all TLS certificates and handles all incoming HTTPS traffic on port 443. Backend servers listen on HTTP only and trust X-Forwarded-Proto headers from the proxy.

Internet → nginx (TLS on :443) → Backend (HTTP on :80)

All services share a single IP address on the proxy. nginx routes traffic to the correct backend based on the Host header (virtual hosting).

Services Covered

The following services from this series run behind the proxy:

Service Hostname Backend Post
Matomo matomo.example.com nginx + PHP-FPM Self-Hosting Matomo
Nextcloud cloud.example.com Apache + PHP-FPM Self-Hosting Nextcloud
Syncthing sync.example.com Syncthing (port 8384) Setting Up Syncthing
Roundcube mail.example.com Apache + PHP-FPM Installing Roundcube
PostfixAdmin mailadmin.example.com Apache + PHP-FPM Managing Mail Domains
Rspamd UI mailadmin.example.com/spamd/ Rspamd (port 11334) Spam Filtering with Rspamd

Wildcard TLS Certificates

Rather than obtaining individual certificates for each subdomain, use a wildcard certificate that covers *.example.com. This is configured in the nginx reverse proxy post.

Certificate Acquisition

Using lego with DNS-01 validation:

lego --accept-tos \
  --email admin@example.com \
  --dns <provider> \
  --domains "example.com" \
  --domains "*.example.com" \
  run

DNS-01 is the only ACME challenge type that supports wildcard certificates. It requires API access to your DNS provider. The certificate is stored at:

/etc/lego/example.com/certificates/example.com.crt
/etc/lego/example.com/certificates/example.com.key

Automatic Renewal

A cron job renews the certificate before expiry:

0 3 * * 1 root /usr/local/bin/lego-renew.sh

The renewal script runs lego with the renew command and reloads nginx if the certificate changes:

#!/bin/bash
lego --accept-tos \
  --email admin@example.com \
  --dns <provider> \
  --domains "example.com" \
  --domains "*.example.com" \
  renew --days 30

if [ $? -eq 0 ]; then
    nginx -s reload
fi

nginx Configuration Structure

The reverse proxy configuration is organized as:

/etc/nginx/
├── nginx.conf              # Main config with global settings
├── conf.d/
│   ├── ssl.conf            # Shared TLS parameters
│   ├── proxy-headers.conf  # Shared proxy headers
│   └── default-ssl.conf    # Default catch-all for unknown hosts
└── sites-enabled/
    ├── matomo.conf
    ├── nextcloud.conf
    ├── syncthing.conf
    ├── roundcube.conf
    └── mailadmin.conf

Shared TLS Parameters

Create /etc/nginx/conf.d/ssl.conf:

ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
ssl_stapling on;
ssl_stapling_verify on;

Shared Proxy Headers

Create /etc/nginx/conf.d/proxy-headers.conf:

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;

These headers tell the backend the original client IP and whether the connection was HTTPS, since the backend only sees the proxy's local connection.

Default SSL Catch-All

Unknown hostnames are rejected immediately instead of serving a random service's content:

server {
    listen 443 ssl default_server;
    server_name _;

    ssl_certificate /etc/lego/example.com/certificates/example.com.crt;
    ssl_certificate_key /etc/lego/example.com/certificates/example.com.key;

    return 444;
}

The 444 response is nginx-specific — it closes the connection without sending a response. This prevents reconnaissance of what services you host.

Per-Service Proxy Configuration

Each service gets a server block that terminates TLS and forwards to the backend.

Standard HTTP Backend

Most services follow this pattern:

server {
    listen 443 ssl http2;
    server_name matomo.example.com;

    ssl_certificate /etc/lego/example.com/certificates/example.com.crt;
    ssl_certificate_key /etc/lego/example.com/certificates/example.com.key;

    location / {
        proxy_pass http://backend-ip:80;
        include conf.d/proxy-headers.conf;
    }
}

WebSocket Backend

Syncthing and some other services need WebSocket support:

server {
    listen 443 ssl http2;
    server_name sync.example.com;

    ssl_certificate /etc/lego/example.com/certificates/example.com.crt;
    ssl_certificate_key /etc/lego/example.com/certificates/example.com.key;

    location / {
        proxy_pass http://backend-ip:8384;
        include conf.d/proxy-headers.conf;

        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

The Upgrade and Connection headers are required for WebSocket connections. Without them, WebSocket handshakes fail and real-time features break.

Path-Based Routing

Multiple services can share a hostname using path-based routing. The mail admin interface combines PostfixAdmin and Rspamd:

server {
    listen 443 ssl http2;
    server_name mailadmin.example.com;

    ssl_certificate /etc/lego/example.com/certificates/example.com.crt;
    ssl_certificate_key /etc/lego/example.com/certificates/example.com.key;

    location /postfixadmin/ {
        proxy_pass http://backend-ip:80/postfixadmin/;
        include conf.d/proxy-headers.conf;
    }

    location /spamd/ {
        proxy_pass http://127.0.0.1:11334/;
        include conf.d/proxy-headers.conf;
    }

    location / {
        proxy_pass http://backend-ip:80;
        include conf.d/proxy-headers.conf;
    }
}

Rspamd binds to localhost, so its proxy target is 127.0.0.1:11334 rather than a remote backend.

HTTP to HTTPS Redirect

Force all traffic through TLS:

server {
    listen 80 default_server;
    server_name _;
    return 301 https://$host$request_uri;
}

This catches all HTTP requests and redirects them to HTTPS, preserving the hostname and path.

Access Control

Basic Authentication

For admin interfaces that shouldn't be publicly accessible, add HTTP basic auth at the proxy level:

location /spamd/ {
    auth_basic "Admin";
    auth_basic_user_file /etc/nginx/.htpasswd;

    proxy_pass http://127.0.0.1:11334/;
    include conf.d/proxy-headers.conf;
}

Generate the password file:

printf "admin:$(openssl passwd -apr1)\n" >> /etc/nginx/.htpasswd

IP Restrictions

Restrict admin interfaces to specific networks:

location /postfixadmin/ {
    allow 192.168.1.0/24;
    allow 10.0.0.0/8;
    deny all;

    proxy_pass http://backend-ip:80/postfixadmin/;
    include conf.d/proxy-headers.conf;
}

Backend Configuration

Trusting Proxy Headers

Backend services must trust the X-Forwarded-Proto header from the proxy so they generate correct HTTPS URLs. In Apache:

SetEnvIf X-Forwarded-Proto "https" HTTPS=on

In Nextcloud's config.php:

'trusted_proxies' => ['proxy-ip'],
'overwriteprotocol' => 'https',

Without this, applications generate HTTP links even though users access them via HTTPS, causing mixed content warnings or redirect loops.

Upload Size Limits

nginx has a default upload limit of 1MB. For services that handle file uploads (Nextcloud, Roundcube with attachments), increase it:

client_max_body_size 512M;

Set this in the server block or location block for the relevant service. The backend (Apache/PHP) must also have its own upload limits configured to match.

Adding a New Service

To add a new service behind the proxy:

  1. Deploy the application on the backend server, listening on HTTP
  2. Configure the backend to trust proxy headers (X-Forwarded-Proto)
  3. Create an nginx server block on the proxy with the hostname and backend address
  4. Add a DNS record (A or CNAME) for the new hostname — the wildcard certificate already covers it
  5. Reload nginx: nginx -s reload

The wildcard certificate eliminates the need to obtain new certificates for each subdomain.

Troubleshooting

502 Bad Gateway

nginx cannot reach the backend. Check the backend is running and listening:

curl -s http://backend-ip:80/ -o /dev/null -w '%{http_code}'

Redirect Loops

The backend generates HTTPS redirects but doesn't trust the proxy header. Ensure X-Forwarded-Proto is set and the backend is configured to use it.

Mixed Content Warnings

The application generates HTTP URLs for assets. Configure the application to force HTTPS URLs, either through its configuration or by trusting the proxy's X-Forwarded-Proto header.

Timeout on Large Uploads

Increase proxy timeouts for services with long-running requests:

proxy_read_timeout 300s;
proxy_send_timeout 300s;

Summary

The reverse proxy architecture provides:

  • Single point of TLS termination with wildcard certificates
  • Hostname-based and path-based routing to multiple backends
  • Centralized access control (basic auth, IP restrictions)
  • HTTP to HTTPS redirect for all services
  • Default catch-all that rejects unknown hostnames
  • Easy addition of new services without new certificates

All self-hosted services in this series — Matomo, Nextcloud, Syncthing, Roundcube, PostfixAdmin, and Rspamd — run behind this proxy with a consistent security posture.