Virtual Web Servers

Posted on 19 2026

Every website or service served by nginx is a virtual server: a server {} block in a configuration file under sites-available/. This page covers the structure of a virtual server configuration and shows three common patterns used throughout this series.

The source material shows a static website with a Tor hidden service, dehydrated certificate paths, and TLS session ticket key rotation via a cron job marked tbd. This page replaces all of that with current patterns using Let’s Encrypt certificates, the modern http2 directive, and the snippet approach established in the previous pages.

The anatomy of a virtual server

Every HTTPS virtual server in this series follows the same structure:

server {}                    # HTTP: redirect to HTTPS and handle ACME
server {}                    # HTTPS alias: redirect to canonical hostname
server {}                    # HTTPS canonical: the actual server

The three-block pattern handles hostname canonicalisation cleanly. Requests to http://www.yourdomain.net redirect to https://yourdomain.net. Requests to https://www.yourdomain.net also redirect to https://yourdomain.net. Only https://yourdomain.net serves content.

Pattern 1: Static website

A site serving static HTML, CSS, and assets from a directory on disk. No backend, no PHP, no proxying.

Create /etc/nginx/sites-available/yourdomain.net:

#
# yourdomain.net - Static website
# /etc/nginx/sites-available/yourdomain.net
#

# HTTP: redirect to HTTPS and serve ACME challenges
server {
    listen 80;
    listen [::]:80;
    server_name yourdomain.net www.yourdomain.net;

    # ACME challenge for certificate renewal
    location /.well-known/acme-challenge/ {
        root /var/www/acme-challenge;
        allow all;
    }

    # Redirect everything else to HTTPS canonical hostname
    location / {
        return 301 https://yourdomain.net$request_uri;
    }
}

# HTTPS alias: redirect www to canonical
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name www.yourdomain.net;

    ssl_certificate /etc/letsencrypt/live/yourdomain.net/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.net/privkey.pem;
    ssl_trusted_certificate /etc/letsencrypt/live/yourdomain.net/chain.pem;

    # Unique TLS session cache for this server
    ssl_session_cache shared:yourdomain_net:10m;

    return 301 https://yourdomain.net$request_uri;
}

# HTTPS canonical server
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name yourdomain.net;

    ssl_certificate /etc/letsencrypt/live/yourdomain.net/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.net/privkey.pem;
    ssl_trusted_certificate /etc/letsencrypt/live/yourdomain.net/chain.pem;

    ssl_session_cache shared:yourdomain_net:10m;

    # Document root
    root /var/www/yourdomain.net;
    index index.html;

    # Security snippets
    include snippets/deny-sensitive-files.conf;
    include snippets/security-headers.conf;
    include snippets/error-pages.conf;

    # Browser cache control for static assets
    include snippets/browser-cache.conf;

    # Content Security Policy for a pure static site
    # Adjust as needed for any scripts or external resources
    add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'none'; frame-ancestors 'none'; form-action 'self'; base-uri 'self'; upgrade-insecure-requests;" always;

    # Serve static files; return 404 if not found
    location / {
        try_files $uri $uri/ =404;
    }

    access_log /var/log/nginx/yourdomain.net.access.log main;
    error_log /var/log/nginx/yourdomain.net.error.log;
}

Create the document root:

sudo mkdir -p /var/www/yourdomain.net
sudo chown www-data:www-data /var/www/yourdomain.net

Pattern 2: Reverse proxy

A service running on a local port, exposed via nginx with TLS. This is the pattern used by Nextcloud, SnappyMail, SnappyMail, Jellyfin, and every other backend service in this series.

Create /etc/nginx/sites-available/nextcloud.yourdomain.net:

#
# Nextcloud reverse proxy
# /etc/nginx/sites-available/nextcloud.yourdomain.net
#

# HTTP: redirect to HTTPS
server {
    listen 80;
    listen [::]:80;
    server_name nextcloud.yourdomain.net;

    location /.well-known/acme-challenge/ {
        root /var/www/acme-challenge;
        allow all;
    }

    location / {
        return 301 https://nextcloud.yourdomain.net$request_uri;
    }
}

# HTTPS reverse proxy
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name nextcloud.yourdomain.net;

    ssl_certificate /etc/letsencrypt/live/nextcloud.yourdomain.net/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/nextcloud.yourdomain.net/privkey.pem;
    ssl_trusted_certificate /etc/letsencrypt/live/nextcloud.yourdomain.net/chain.pem;

    ssl_session_cache shared:nextcloud:10m;

    # Nextcloud requires a higher client body size for file uploads
    client_max_body_size 10G;
    client_body_timeout 300s;
    client_body_buffer_size 512k;

    # Security snippets
    include snippets/deny-sensitive-files.conf;
    include snippets/security-headers.conf;
    include snippets/error-pages.conf;

    # Nextcloud-specific Content Security Policy
    # Nextcloud sets its own CSP; this is a permissive fallback
    # Review and tighten after Nextcloud is working
    add_header Content-Security-Policy "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'self'; upgrade-insecure-requests;" always;

    # Proxy all requests to the Nextcloud backend
    location / {
        proxy_pass http://127.0.0.1:8080;
        include snippets/proxy-headers.conf;

        # Extended timeouts for Nextcloud operations
        proxy_read_timeout 300s;
        proxy_send_timeout 300s;
    }

    # WebDAV and CalDAV/CardDAV well-known redirects
    location = /.well-known/carddav {
        return 301 $scheme://$host/remote.php/dav;
    }
    location = /.well-known/caldav {
        return 301 $scheme://$host/remote.php/dav;
    }

    # ACME challenge access
    location /.well-known/acme-challenge/ {
        root /var/www/acme-challenge;
        allow all;
    }

    access_log /var/log/nginx/nextcloud.access.log main;
    error_log /var/log/nginx/nextcloud.error.log;
}

Pattern 3: Internal-only service

A service that should never be accessible from the public internet. PostfixAdmin, the Mosquitto management interface, Proxmox, and similar admin tools fall into this category.

Create /etc/nginx/sites-available/admin.yourdomain.net:

#
# Internal-only service
# /etc/nginx/sites-available/admin.yourdomain.net
#
# This service is accessible only from internal network addresses.
# Certificates are from the internal CA, not Let's Encrypt.
#

# HTTP: redirect to HTTPS (internal only)
server {
    listen 80;
    listen [::]:80;
    server_name admin.yourdomain.net;

    # Restrict to internal addresses even on HTTP
    include snippets/internal-only.conf;

    return 301 https://admin.yourdomain.net$request_uri;
}

# HTTPS: internal CA certificate
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name admin.yourdomain.net;

    # Certificate from internal CA (trusted by desktop via system trust store)
    ssl_certificate /etc/ssl/certs/admin.yourdomain.net.crt;
    ssl_certificate_key /etc/ssl/private/admin.yourdomain.net.key;

    ssl_session_cache shared:admin:10m;

    # Restrict to internal networks only
    include snippets/internal-only.conf;

    # Security snippets
    include snippets/security-headers.conf;
    include snippets/error-pages.conf;

    # No browser cache for admin interfaces
    add_header Cache-Control 'no-store, no-cache, must-revalidate' always;

    # Proxy to backend admin service
    location / {
        proxy_pass http://127.0.0.1:8080;
        include snippets/proxy-headers.conf;
    }

    access_log /var/log/nginx/admin.access.log main;
    error_log /var/log/nginx/admin.error.log;
}

TLS certificate paths

The source material uses dehydrated certificate paths. This series uses certbot and Let’s Encrypt. The certificate paths for certbot are:

FilePath
Certificate + chain/etc/letsencrypt/live/hostname/fullchain.pem
Private key/etc/letsencrypt/live/hostname/privkey.pem
Chain only (for OCSP)/etc/letsencrypt/live/hostname/chain.pem

For internal CA certificates:

FilePath
Certificate/etc/ssl/certs/hostname.crt
Private key/etc/ssl/private/hostname.key

TLS session tickets

The source material includes TLS session ticket key rotation via a cron job marked tbd. In the global HTTP settings, ssl_session_tickets off was set deliberately. Session tickets without careful key rotation undermine forward secrecy, since a compromised ticket key allows decryption of past sessions. With tickets disabled, the ssl_session_cache handles session resumption instead.

If session tickets are needed for performance reasons, implement key rotation:

# Generate a new session ticket key
sudo openssl rand 80 > /etc/nginx/session_ticket.key
sudo chmod 640 /etc/nginx/session_ticket.key
sudo chown root:www-data /etc/nginx/session_ticket.key

Add to the server block:

ssl_session_tickets on;
ssl_session_ticket_key /etc/nginx/session_ticket.key;

Rotate the key periodically via anacron:

cat > ~/.anacron/cron.weekly/rotate-nginx-session-keys << 'EOF'
#!/usr/bin/env bash
sudo openssl rand 80 > /etc/nginx/session_ticket.key.new
sudo mv /etc/nginx/session_ticket.key.new /etc/nginx/session_ticket.key
sudo systemctl reload nginx
EOF

chmod 0755 ~/.anacron/cron.weekly/rotate-nginx-session-keys

For most self-hosted deployments, ssl_session_tickets off with ssl_session_cache is simpler and sufficient.

Activating a virtual server

# Create the symlink to activate
sudo ln -s /etc/nginx/sites-available/yourdomain.net \
    /etc/nginx/sites-enabled/yourdomain.net

# Test configuration
sudo nginx -t

# Reload nginx
sudo systemctl reload nginx

# Verify the site is responding
curl -I https://yourdomain.net

HTTP/2 note

The source material uses listen 443 ssl http2 on the listen directive. This syntax was deprecated in nginx 1.25.1. The current syntax separates http2 into its own directive:

listen 443 ssl;        # Not: listen 443 ssl http2;
http2 on;              # Separate directive

The old syntax still works but generates a deprecation warning in nginx logs. Use the new syntax for all new configurations.

Every virtual server should have its own ssl_session_cache name to avoid cache collisions. The cache name in ssl_session_cache shared:NAME:SIZE must be unique across all server blocks. Use the hostname with dots replaced by underscores: shared:yourdomain_net:10m.