Every service you expose to a network -- whether the public internet or your home LAN -- should use TLS encryption. Without it, passwords, API keys, and personal data travel in plaintext, readable by anyone with access to the network path. With Let's Encrypt providing free certificates and tools like certbot automating the process, there is no excuse for running unencrypted services in 2025.

This guide covers TLS from the fundamentals up through advanced topics like wildcard certificates, reverse proxy TLS termination, HSTS preloading, and mutual TLS (mTLS) for service-to-service authentication.

How TLS Works

TLS (Transport Layer Security) is the protocol that secures HTTPS connections. When your browser connects to a website over HTTPS, a TLS handshake occurs before any application data is exchanged:

  1. Client Hello: The client sends supported TLS versions, cipher suites, and a random number.
  2. Server Hello: The server selects a TLS version and cipher suite, sends its certificate and a random number.
  3. Certificate Verification: The client verifies the server's certificate against its trusted Certificate Authority (CA) store.
  4. Key Exchange: Client and server use asymmetric cryptography (typically ECDHE) to agree on a shared secret.
  5. Symmetric Encryption: All subsequent data is encrypted with the shared secret using a fast symmetric cipher (AES-256-GCM or ChaCha20-Poly1305).

TLS 1.3 simplifies this to a single round trip (1-RTT), reducing latency compared to TLS 1.2's two round trips. TLS 1.3 also removes legacy cipher suites that were known to be weak.

SSL vs. TLS: SSL (Secure Sockets Layer) is the predecessor to TLS. SSL 3.0 was deprecated in 2015 due to the POODLE vulnerability. When people say "SSL certificate," they almost always mean a TLS certificate. The terms are used interchangeably in practice, but TLS is the correct modern term.

Certificate Types

Type Validation Cost Use Case
DV (Domain Validated) Domain ownership only Free (Let's Encrypt) Personal sites, APIs, self-hosted
OV (Organization Validated) Domain + org verification $50-200/year Business websites
EV (Extended Validation) Thorough org verification $100-500/year Financial institutions, e-commerce
Wildcard (*.example.com) Domain ownership Free (Let's Encrypt) Multiple subdomains
Self-signed None Free Internal/dev only (triggers browser warnings)

For self-hosted infrastructure, DV certificates from Let's Encrypt are the right choice in almost every scenario. They are free, automatically renewable, and trusted by all modern browsers and operating systems.

Let's Encrypt with Certbot

HTTP Challenge (Port 80)

The simplest method. Certbot places a file on your web server, and Let's Encrypt verifies it via HTTP. Requires port 80 to be accessible from the internet:

# Install certbot
sudo apt install -y certbot

# Standalone mode (certbot runs its own web server)
sudo certbot certonly --standalone \
  -d myserver.example.com \
  --email [email protected] \
  --agree-tos \
  --non-interactive

# Webroot mode (certbot places files in your existing web server)
sudo certbot certonly --webroot \
  -w /var/www/html \
  -d myserver.example.com

# Certificates are stored in:
# /etc/letsencrypt/live/myserver.example.com/fullchain.pem
# /etc/letsencrypt/live/myserver.example.com/privkey.pem

# Test automatic renewal
sudo certbot renew --dry-run

DNS Challenge (No Port 80 Required)

The DNS challenge proves domain ownership by creating a TXT record. This works even if your server is not publicly accessible and is the only way to get wildcard certificates:

# Install certbot with Cloudflare DNS plugin
sudo apt install -y certbot python3-certbot-dns-cloudflare

# Create Cloudflare credentials file
sudo mkdir -p /etc/letsencrypt
sudo tee /etc/letsencrypt/cloudflare.ini <<EOF
dns_cloudflare_api_token = your-cloudflare-api-token
EOF
sudo chmod 600 /etc/letsencrypt/cloudflare.ini

# Request a wildcard certificate via DNS challenge
sudo certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  -d "example.com" \
  -d "*.example.com" \
  --email [email protected] \
  --agree-tos \
  --non-interactive

# This creates certificates valid for both example.com and *.example.com
Tip: DNS challenge plugins exist for most major DNS providers: Cloudflare, Route53, DigitalOcean, Hetzner, OVH, and many more. Check apt search python3-certbot-dns for available plugins.

Wildcard Certificates

A wildcard certificate covers all subdomains of a domain (e.g., *.example.com covers app.example.com, grafana.example.com, etc.). This is ideal for self-hosted setups where you run many services on subdomains:

# A single wildcard cert handles all your services:
# https://grafana.example.com
# https://nextcloud.example.com
# https://gitea.example.com
# https://usulnet.example.com

# Important: *.example.com does NOT cover example.com itself
# or nested subdomains like sub.app.example.com
# You need to request both the bare domain and the wildcard:
sudo certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  -d "example.com" \
  -d "*.example.com"

Reverse Proxy TLS Termination

In most self-hosted setups, you do not configure TLS on each individual service. Instead, a reverse proxy handles TLS termination: it accepts HTTPS connections from clients, decrypts them, and forwards plain HTTP to backend services on the internal network.

Nginx TLS Termination

# /etc/nginx/sites-available/grafana.example.com
server {
    listen 80;
    server_name grafana.example.com;
    return 301 https://$server_name$request_uri;
}

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

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

    # Modern TLS configuration
    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;

    # OCSP Stapling
    ssl_stapling on;
    ssl_stapling_verify on;
    resolver 1.1.1.1 1.0.0.1 valid=300s;

    # Security headers
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-XSS-Protection "1; mode=block" always;

    location / {
        proxy_pass http://localhost:3000;
        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;
    }
}

Traefik Automatic TLS

Traefik can automatically obtain and renew Let's Encrypt certificates for any service it discovers via Docker labels:

# traefik.yml (static configuration)
entryPoints:
  web:
    address: ":80"
    http:
      redirections:
        entryPoint:
          to: websecure
          scheme: https
  websecure:
    address: ":443"

certificatesResolvers:
  letsencrypt:
    acme:
      email: [email protected]
      storage: /acme.json
      dnsChallenge:
        provider: cloudflare
        resolvers:
          - "1.1.1.1:53"
          - "8.8.8.8:53"

providers:
  docker:
    exposedByDefault: false

# Docker service with automatic TLS
# docker-compose.yml
services:
  grafana:
    image: grafana/grafana:latest
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.grafana.rule=Host(`grafana.example.com`)"
      - "traefik.http.routers.grafana.entrypoints=websecure"
      - "traefik.http.routers.grafana.tls.certresolver=letsencrypt"

HSTS: HTTP Strict Transport Security

HSTS tells browsers to always use HTTPS for your domain, even if the user types http://. This prevents SSL stripping attacks and eliminates the HTTP-to-HTTPS redirect round trip:

# Add the HSTS header in your web server or reverse proxy
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

# max-age=63072000  - Remember for 2 years
# includeSubDomains - Apply to all subdomains
# preload           - Eligible for browser preload list
Warning: HSTS with includeSubDomains is a commitment. Once browsers cache this header, they will refuse to connect via HTTP for the duration of max-age. If you later need to serve a subdomain over HTTP, you cannot. Start with a short max-age (e.g., 300 seconds) during testing, and increase it only when you are confident all subdomains support HTTPS.

Certificate Monitoring and Renewal

Let's Encrypt certificates expire after 90 days. Certbot's automatic renewal handles this, but you should monitor for failures:

# Certbot automatically installs a systemd timer
systemctl list-timers | grep certbot

# Manual renewal check
sudo certbot renew --dry-run

# Monitor certificate expiry with a script
#!/bin/bash
# check-certs.sh
DOMAINS="example.com grafana.example.com nextcloud.example.com"
WARN_DAYS=14

for domain in $DOMAINS; do
  expiry=$(echo | openssl s_client -connect "$domain:443" -servername "$domain" 2>/dev/null | \
    openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)

  if [ -n "$expiry" ]; then
    expiry_epoch=$(date -d "$expiry" +%s)
    now_epoch=$(date +%s)
    days_left=$(( (expiry_epoch - now_epoch) / 86400 ))

    if [ "$days_left" -lt "$WARN_DAYS" ]; then
      echo "WARNING: $domain expires in $days_left days ($expiry)"
    else
      echo "OK: $domain expires in $days_left days"
    fi
  else
    echo "ERROR: Could not check $domain"
  fi
done

# Check certificate details with openssl
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null | \
  openssl x509 -noout -text | grep -A2 "Validity"

# Check which domains a certificate covers
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null | \
  openssl x509 -noout -ext subjectAltName

Mutual TLS (mTLS)

Standard TLS is one-way: the client verifies the server's identity. Mutual TLS adds a second layer: the server also verifies the client's identity using a client certificate. This is useful for service-to-service communication where you want to ensure only authorized services can connect:

# Generate a Certificate Authority (CA)
openssl genrsa -out ca.key 4096
openssl req -new -x509 -days 3650 -key ca.key -out ca.crt \
  -subj "/CN=Internal CA/O=Homelab"

# Generate a server certificate
openssl genrsa -out server.key 2048
openssl req -new -key server.key -out server.csr \
  -subj "/CN=api.internal"
openssl x509 -req -days 365 -in server.csr \
  -CA ca.crt -CAkey ca.key -CAcreateserial \
  -out server.crt

# Generate a client certificate
openssl genrsa -out client.key 2048
openssl req -new -key client.key -out client.csr \
  -subj "/CN=worker-01"
openssl x509 -req -days 365 -in client.csr \
  -CA ca.crt -CAkey ca.key -CAcreateserial \
  -out client.crt

# Nginx mTLS configuration
server {
    listen 443 ssl;
    server_name api.internal;

    ssl_certificate     /etc/nginx/certs/server.crt;
    ssl_certificate_key /etc/nginx/certs/server.key;

    # Require client certificate
    ssl_client_certificate /etc/nginx/certs/ca.crt;
    ssl_verify_client on;

    location / {
        proxy_pass http://backend:8080;
        proxy_set_header X-Client-CN $ssl_client_s_dn_cn;
    }
}

# Test with curl using client certificate
curl --cert client.crt --key client.key \
  --cacert ca.crt \
  https://api.internal/health
Tip: For internal service-to-service TLS in a Docker environment, usulnet includes built-in PKI (Public Key Infrastructure) that automatically generates and manages TLS certificates for secure agent-to-master communication, removing the need for manual certificate management.

Self-Signed Certificates for Internal Use

For services that are only accessible on your LAN and not exposed to the internet, self-signed certificates provide encryption without the complexity of Let's Encrypt:

# Generate a self-signed certificate valid for 10 years
openssl req -x509 -nodes -days 3650 \
  -newkey rsa:2048 \
  -keyout /etc/ssl/private/selfsigned.key \
  -out /etc/ssl/certs/selfsigned.crt \
  -subj "/CN=homelab.local" \
  -addext "subjectAltName=DNS:homelab.local,DNS:*.homelab.local,IP:192.168.1.100"

# To avoid browser warnings, add the CA to your system trust store:
# On Ubuntu/Debian:
sudo cp selfsigned.crt /usr/local/share/ca-certificates/homelab.crt
sudo update-ca-certificates

# On macOS:
sudo security add-trusted-cert -d -r trustRoot \
  -k /Library/Keychains/System.keychain selfsigned.crt

TLS Best Practices Checklist

  • Use TLS 1.2 or 1.3 exclusively. Disable TLS 1.0 and 1.1.
  • Enable HSTS with a long max-age once you have verified HTTPS works correctly.
  • Use OCSP stapling to improve TLS handshake performance.
  • Monitor certificate expiry dates and set up alerts for certificates expiring within 14 days.
  • Automate renewal with certbot or your reverse proxy's built-in ACME support.
  • Use the DNS challenge for wildcard certificates and servers behind firewalls.
  • Never expose private keys in logs, version control, or error messages.
  • Test your configuration regularly with ssllabs.com/ssltest or testssl.sh.
# Test your TLS configuration with testssl.sh
git clone --depth 1 https://github.com/drwetter/testssl.sh.git
cd testssl.sh
./testssl.sh https://example.com