Nginx Webserver (Debian)

Baseline setup + secure vhosts + DNS-01 TLS

This document describes a practical and hardened Nginx setup on Debian. All examples are anonymized using example.nl and hostname.example.nl.

Design goals:

  • Safe defaults (no banner leakage, strict TLS, strict headers)
  • Catch-all behavior: silent close for unknown vhosts (return 444)
  • Clean vhost template per domain
  • DNS-01 (API) certificate issuance with ECDSA key reuse
  • RFC endpoints supported: security.txt and MTA-STS

1) Variables

Adjust only these values

# Domain (main website)
DOMAIN="example.nl"

# Default TLS identity for non-SNI HTTPS catch-all
DEFAULT_TLS_HOST="hostname.example.nl"

# Webroot per domain
WEBROOT="/var/www/${DOMAIN}/html"

2) Install packages

Nginx + Certbot + DNS plugin

sudo apt update
sudo apt install -y nginx openssl certbot python3-certbot-dns-transip

sudo systemctl enable --now nginx

3) File layout

Where everything lives

# Main config
/etc/nginx/nginx.conf

# Common headers include (loaded inside server blocks)
/etc/nginx/conf.d/headers.conf

# Catch-all default vhosts
/etc/nginx/sites-available/00-default-http.conf
/etc/nginx/sites-available/00-default-ssl.conf

# Per-domain vhosts
/etc/nginx/sites-available/example.nl.conf
/etc/nginx/sites-enabled/example.nl.conf

# Website root
/var/www/example.nl/html

4) DH params (FFDHE4096)

Used for DHE cipher suites (optional but fine)

sudo openssl dhparam -out /etc/nginx/ffdhe4096.pem 4096
sudo chmod 0644 /etc/nginx/ffdhe4096.pem

5) nginx.conf baseline

Clean logging + gzip + vhost includes

sudo tee /etc/nginx/nginx.conf > /dev/null <<'EOF'
user www-data;
worker_processes auto;
pid /run/nginx.pid;

include /etc/nginx/modules-enabled/*.conf;

events {
    worker_connections 768;
}

http {

    ##
    # Basic Settings
    ##

    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;

    keepalive_timeout 65;

    types_hash_max_size 2048;

    # Rate limiting zone (optional - enable in server/location when needed)
    limit_req_zone $binary_remote_addr zone=app:10m rate=2r/s;

    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    ##
    # Logging
    ##

    log_format custom
        '$remote_addr - [$time_local] - $ssl_protocol - $ssl_cipher '
        '"$request" - $status - $body_bytes_sent - "$http_referer" - '
        '"$http_user_agent" - $upstream_addr - $upstream_status - $remote_user - $host - '
        '$request_time - $upstream_response_time - $upstream_connect_time - $upstream_header_time';

    access_log /var/log/nginx/access.log;
    error_log  /var/log/nginx/error.log;

    ##
    # Gzip
    ##

    gzip on;
    gzip_disable "msie6";

    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 5;
    gzip_buffers 16 8k;
    gzip_http_version 1.1;
    gzip_min_length 256;
    gzip_types
        text/plain
        text/css
        application/json
        application/javascript
        text/xml
        application/xml
        application/xml+rss
        text/javascript;

    ##
    # Virtual Host Configs
    ##

    include /etc/nginx/conf.d/*.conf;
    include /etc/nginx/sites-enabled/*.conf;
}
EOF
sudo nginx -t
sudo systemctl reload nginx

6) Security headers include

/etc/nginx/conf.d/headers.conf

This include is loaded inside the server blocks. Important: CSP depends on your website. If you embed external assets, update it accordingly.

sudo tee /etc/nginx/conf.d/headers.conf > /dev/null <<'EOF'
# ------------------------------------------------------------
# Security headers - hardened
# ------------------------------------------------------------

# HSTS (only effective over HTTPS)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

# Disable MIME sniffing
add_header X-Content-Type-Options "nosniff" always;

# Clickjacking prevention
add_header X-Frame-Options "DENY" always;

# Referrer privacy
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

# Permissions Policy - locked down
add_header Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()" always;

# ------------------------------------------------------------
# Content Security Policy (strict baseline)
# ------------------------------------------------------------
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self' https://formsubmit.co; frame-ancestors 'none'; base-uri 'none'; object-src 'none'" always;

# Remove server banner
server_tokens off;
EOF
sudo nginx -t
sudo systemctl reload nginx

7) Default catch-all vhosts

HTTP + HTTPS (return 444)

The goal is to catch unknown hostnames and close silently. HTTPS catch-all uses hostname.example.nl certificate for non-SNI clients.

7.1) Default HTTP

sudo tee /etc/nginx/sites-available/00-default-http.conf > /dev/null <<'EOF'
##
# Default HTTP catch-all
# - Catches all HTTP requests without explicit vhost
# - No redirect, no content
# - Silent close (infra-host behavior)
##
server {
    listen 80 default_server;
    listen [::]:80 default_server;

    server_name _;

    access_log /var/log/nginx/default-http.access.log custom;
    error_log  /var/log/nginx/default-http.error.log warn;

    return 444;
}
EOF

7.2) Default HTTPS

sudo tee /etc/nginx/sites-available/00-default-ssl.conf > /dev/null <<'EOF'
##
# Default HTTPS / TLS catch-all
# - Used for clients without SNI
# - Presents server identity: hostname.example.nl
# - No content, no redirect
##
server {
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;

    server_name _;

    http2 on;

    ssl_certificate     /etc/letsencrypt/live/hostname.example.nl/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/hostname.example.nl/privkey.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_session_cache shared:defaultSSL:10m;
    ssl_session_timeout 1h;
    ssl_session_tickets off;

    access_log /var/log/nginx/default-ssl.access.log custom;
    error_log  /var/log/nginx/default-ssl.error.log warn;

    return 444;
}
EOF
sudo ln -sf /etc/nginx/sites-available/00-default-http.conf /etc/nginx/sites-enabled/00-default-http.conf
sudo ln -sf /etc/nginx/sites-available/00-default-ssl.conf  /etc/nginx/sites-enabled/00-default-ssl.conf

sudo nginx -t
sudo systemctl reload nginx

8) Domain vhost template

example.nl with RFC endpoints

This is one vhost file per domain. It includes: /.well-known/security.txt and /.well-known/mta-sts.txt.

sudo tee /etc/nginx/sites-available/example.nl.conf > /dev/null <<'EOF'
server {
    listen 80;
    listen [::]:80;
    server_name example.nl;
    return 301 https://$host$request_uri;
    include /etc/nginx/conf.d/headers.conf;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name example.nl;

    http2 on;

    ## SSL settings
    ssl_certificate     /etc/letsencrypt/live/example.nl/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.nl/privkey.pem;
    ssl_dhparam         /etc/nginx/ffdhe4096.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on;

    ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384";
    ssl_session_cache shared:le_nginx_SSL:1m;
    ssl_session_timeout 1440m;
    ssl_session_tickets off;

    ## Log settings
    access_log /var/log/nginx/example.nl.access.log custom;
    error_log  /var/log/nginx/example.nl.error.log warn;

    ## Headers
    include /etc/nginx/conf.d/headers.conf;

    ## OCSP stapling (disabled by design)
    ssl_stapling off;
    ssl_stapling_verify off;

    ## Website settings
    root /var/www/example.nl/html;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }

    # RFC 9116 - security.txt
    location = /.well-known/security.txt {
        default_type text/plain;
        add_header Cache-Control "max-age=86400";
        try_files $uri =404;
    }

    # RFC 8461 - MTA-STS
    location = /.well-known/mta-sts.txt {
        default_type text/plain;
        add_header Cache-Control "max-age=3600, no-transform";
        try_files $uri =404;
    }
}
EOF
sudo ln -sf /etc/nginx/sites-available/example.nl.conf /etc/nginx/sites-enabled/example.nl.conf
sudo nginx -t
sudo systemctl reload nginx

8.1) Create website directories

sudo install -d -m 0755 -o www-data -g www-data /var/www/example.nl/html
sudo install -d -m 0755 -o www-data -g www-data /var/www/example.nl/html/.well-known

8.2) Example endpoint files

sudo tee /var/www/example.nl/html/.well-known/security.txt > /dev/null <<'EOF'
Contact: mailto:security@example.nl
Preferred-Languages: nl, en
Canonical: https://example.nl/.well-known/security.txt
Expires: 2030-01-01T00:00:00+00:00
EOF
sudo tee /var/www/example.nl/html/.well-known/mta-sts.txt > /dev/null <<'EOF'
version: STSv1
mode: enforce
mx: mail.example.nl
max_age: 604800
EOF

Certbot DNS-01 (TransIP plugin)

certbot-dns-transip via pip (Debian)

Debian does not provide a python3-certbot-dns-transip package via APT. Therefore we install the TransIP DNS plugin using pip. This plugin enables DNS-01 validation (useful for multi-host certificates and wildcards).

1) Install required packages

sudo apt update
sudo apt install -y certbot python3-pip

2) Install the TransIP DNS plugin (pip)

The TransIP plugin is typically installed into /usr/local. On modern Debian releases this often requires --break-system-packages.

sudo python3 -m pip install --break-system-packages certbot-dns-transip

3) Verify the plugin is available

certbot plugins | grep -i transip || true
python3 -c "import certbot_dns_transip; print('certbot_dns_transip: OK')"

4) Create the credentials file

Create a credentials file for the TransIP plugin. This file must be root-only (privacy + security best practice).

sudo install -d -m 0750 /etc/letsencrypt/transip

sudo tee /etc/letsencrypt/transip/api.ini > /dev/null <<'EOF'
dns_transip_username = username
dns_transip_key_file = /root/.transip_api_key.key
EOF

sudo chmod 0600 /etc/letsencrypt/transip/api.ini

5) Permissions check

sudo ls -l /etc/letsencrypt/transip/api.ini
sudo ls -l /root/.transip_api_key.key

Expected: -rw------- (0600) for both the credentials file and the key file.

6) Example DNS-01 certificate request

Prefer testing with --test-cert to avoid rate limits. Note: DNS-01 will still create real TXT records via TransIP.

sudo certbot certonly \
  --test-cert \
  --authenticator dns-transip \
  --dns-transip-credentials /etc/letsencrypt/transip/api.ini \
  --dns-transip-propagation-seconds 30 \
  -d example.nl

Notes

  • DNS-01 is ideal for multi-host certificates (mail/web) and works without any HTTP endpoint.
  • Keep credentials strictly root-only (chmod 0600).
  • Use --test-cert for safe testing (Let's Encrypt staging).

9) TLS certificates (DNS-01 TransIP)

ECDSA P-256 key reuse per certificate

DNS-01 is used so certificates can be issued without opening HTTP challenges. This also works for internal hosts, mail hosts, and multiple SANs.

9.1) Create ECDSA P-256 keys (one per certificate)

sudo install -d -m 0700 /etc/letsencrypt/keys

# example.nl key
sudo openssl ecparam -name prime256v1 -genkey -noout \
  -out /etc/letsencrypt/keys/example.nl.key
sudo chmod 0600 /etc/letsencrypt/keys/example.nl.key

# hostname.example.nl key
sudo openssl ecparam -name prime256v1 -genkey -noout \
  -out /etc/letsencrypt/keys/hostname.example.nl.key
sudo chmod 0600 /etc/letsencrypt/keys/hostname.example.nl.key

9.2) Issue certificate for example.nl (multi-SAN)

sudo certbot certonly \
  --authenticator dns-transip \
  --dns-transip-credentials /etc/letsencrypt/transip/api.ini \
  --dns-transip-propagation-seconds 30 \
  --cert-name example.nl \
  --key-path /etc/letsencrypt/keys/example.nl.key \
  --reuse-key \
  -d example.nl \
  -d www.example.nl \
  -d mail.example.nl \
  -d imap.example.nl \
  -d smtp.example.nl \
  -d autoconfig.example.nl \
  -d autodiscover.example.nl

9.3) Issue certificate for hostname.example.nl

sudo certbot certonly \
  --authenticator dns-transip \
  --dns-transip-credentials /etc/letsencrypt/transip/api.ini \
  --dns-transip-propagation-seconds 30 \
  --cert-name hostname.example.nl \
  --key-path /etc/letsencrypt/keys/hostname.example.nl.key \
  --reuse-key \
  -d hostname.example.nl

9.4) Reload Nginx after issuance

sudo nginx -t
sudo systemctl reload nginx

9.5) Renewal test

sudo certbot renew --dry-run

10) TLSA / DANE (optional)

SPKI SHA256 hash (3 1 1 and 2 1 1)

DANE requires DNSSEC. These examples generate the SPKI SHA256 hash from the certificate. The hash is used for TLSA record type 3 1 1 (and optionally 2 1 1).

10.1) Generate SPKI SHA256 hash

# Example: hostname.example.nl certificate
openssl x509 -in /etc/letsencrypt/live/hostname.example.nl/cert.pem -noout -pubkey \
  | openssl pkey -pubin -outform DER \
  | openssl dgst -sha256 -binary \
  | xxd -p -c 256

10.2) TLSA record names for HTTPS

_443._tcp.hostname.example.nl.  TLSA 3 1 1 HASH
_443._tcp.hostname.example.nl.  TLSA 2 1 1 HASH

_443._tcp.example.nl.           TLSA 3 1 1 HASH
_443._tcp.example.nl.           TLSA 2 1 1 HASH

11) DNS records

A/AAAA + MTA-STS + TLS-RPT + TLSA

11.1) A / AAAA

example.nl.              A       YOUR_IPV4
example.nl.              AAAA    YOUR_IPV6

hostname.example.nl.     A       YOUR_IPV4
hostname.example.nl.     AAAA    YOUR_IPV6

11.2) MTA-STS and TLS-RPT TXT

# MTA-STS TXT record (per domain)
_mta-sts.example.nl.     TXT "v=STSv1; id=20260101"

# TLS-RPT TXT record (per domain)
_smtp._tls.example.nl.   TXT "v=TLSRPTv1; rua=mailto:tls-report@example.nl"

11.3) TLSA (optional, DNSSEC required)

_443._tcp.hostname.example.nl.  TLSA 3 1 1 HASH
_443._tcp.example.nl.           TLSA 3 1 1 HASH

13) Signed security.txt (RFC 9116)

Template + monthly signed refresh (clear-signed)

This setup keeps /.well-known/security.txt up-to-date and cryptographically signed. The workflow uses:

  • A template file with placeholders
  • A renewal script that updates Expires: and clear-signs the output
  • A cron job to refresh monthly

Note: the endpoint is still served by the existing Nginx location block: location = /.well-known/security.txt { ... }

13.1) Create the template

sudo install -d -m 0755 /var/scripts

sudo tee /var/scripts/security.txt.tpl > /dev/null <<'EOF'
Contact: mailto:security@{{DOMAIN}}
Encryption: openpgp4fpr:A1B2C3D4E5F60718293A4B5C6D7E8091A2B3C4D5
Preferred-Languages: nl, en
Canonical: https://{{DOMAIN}}/.well-known/security.txt
Expires: {{EXPIRES}}
EOF

13.2) Update and sign script

This script generates a clear-signed security.txt per domain. It updates the expiration timestamp to 12 months ahead (UTC).

sudo tee /usr/local/sbin/update-securitytxt.sh > /dev/null <<'EOF'
#!/bin/bash
set -euo pipefail

# --------------------------------------------------
# CONFIG
# --------------------------------------------------

DOMAINS=(
  "example.nl"
  "example2.nl"
  "example3.nl"
  "example4.nl"
)

TPL="/var/scripts/security.txt.tpl"
LOG="/var/log/securitytxt.log"

# GPG identity (fingerprint or keyid)
GPG_ID="A1B2C3D4E5F60718293A4B5C6D7E8091A2B3C4D5"

# Expires: 12 months ahead (UTC)
EXPIRES="$(date -u -d '+12 months' '+%Y-%m-%dT%H:%M:%S+00:00')"

echo "[$(date -u)] Updating security.txt (expires=$EXPIRES)" >> "$LOG"

# --------------------------------------------------
# LOOP
# --------------------------------------------------

for DOMAIN in "${DOMAINS[@]}"; do
    TARGET="/var/www/${DOMAIN}/html/.well-known/security.txt"
    TMP="$(mktemp)"

    echo "  - ${DOMAIN}" >> "$LOG"

    # Fill template placeholders
    sed \
      -e "s/{{DOMAIN}}/${DOMAIN}/g" \
      -e "s/{{EXPIRES}}/${EXPIRES}/" \
      "$TPL" > "$TMP"

    # Clear-sign output
    gpg --batch --yes --clear-sign \
        --local-user "$GPG_ID" \
        --output "$TARGET" \
        "$TMP"

    rm -f "$TMP"
done

echo "[$(date -u)] Done" >> "$LOG"
EOF

sudo chmod 0750 /usr/local/sbin/update-securitytxt.sh

13.3) Cron job (monthly renewal)

sudo tee /etc/cron.d/securitytxt > /dev/null <<'EOF'
# Auto-renew security.txt (RFC 9116)
0 3 1 * * root /usr/local/sbin/update-securitytxt.sh
EOF

13.4) Generate a dedicated GPG signing key (server-side)

Use a dedicated signing key for operational isolation. This example generates an RSA 4096 signing key without passphrase.

sudo tee /root/securitytxt-gpg.conf > /dev/null <<'EOF'
Key-Type: RSA
Key-Length: 4096
Key-Usage: sign
Subkey-Type: RSA
Subkey-Length: 4096
Subkey-Usage: sign
Name-Real: Security Contact
Name-Email: security@example.nl
Name-Comment: security.txt
Expire-Date: 3y
%no-protection
%commit
EOF

gpg --batch --generate-key /root/securitytxt-gpg.conf
gpg --list-secret-keys --keyid-format=long

13.5) Manual test run

sudo /usr/local/sbin/update-securitytxt.sh

13.6) Checks per domain

curl -fsS https://example.nl/.well-known/security.txt
curl -fsS https://example2.nl/.well-known/security.txt
curl -fsS https://example3.nl/.well-known/security.txt
curl -fsS https://example4.nl/.well-known/security.txt

13.7) Verify signature (optional)

gpg --verify /var/www/example.nl/html/.well-known/security.txt

Tip: If you want clients to be able to verify the signature externally, publish the public key fingerprint on a trusted page and/or provide a downloadable public key file.

12) Testing and validation

Commands to verify behavior

12.1) Nginx syntax and loaded config

sudo nginx -t
sudo nginx -T | sed -n '1,200p'

12.2) Service status

sudo systemctl status nginx --no-pager
sudo ss -lntp | grep -E '(:80|:443)\b' || true

12.3) HTTP to HTTPS redirect

curl -I http://example.nl/

12.4) TLS handshake and certificate (SNI)

openssl s_client -connect example.nl:443 -servername example.nl < /dev/null 2>/dev/null | head -n 30

12.5) Verify HTTP/2 negotiation

openssl s_client -connect example.nl:443 -servername example.nl -alpn h2 -tls1_3 < /dev/null 2>/dev/null | grep -iE 'ALPN|Protocol|Cipher' || true

12.6) Default HTTPS catch-all (no SNI)

# If your firewall allows direct-IP testing, replace YOUR_IP
openssl s_client -connect YOUR_IP:443 -noservername < /dev/null 2>/dev/null | head -n 25

12.7) Security headers check

curl -I https://example.nl/ | sed -n '1,80p'

12.8) RFC endpoints

curl -fsS https://example.nl/.well-known/security.txt
curl -fsS https://example.nl/.well-known/mta-sts.txt

12.9) DNS TXT checks

dig TXT _mta-sts.example.nl +short
dig TXT _smtp._tls.example.nl +short

12.10) TLSA checks (DNSSEC required)

dig TLSA _443._tcp.example.nl +dnssec +short
dig TLSA _443._tcp.hostname.example.nl +dnssec +short

12.11) Certbot status

sudo certbot certificates
sudo certbot renew --dry-run