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.txtandMTA-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-certfor 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