Files
2026-07-12 10:17:17 -04:00

5.4 KiB

Self-Hosted Service + nginx Reverse Proxy + DuckDNS + Let's Encrypt

Full pattern for exposing a self-hosted Docker service to the internet with SSL when your ISP blocks ports 80/443.

When to use

  • Setting up any self-hosted service behind nginx with SSL
  • ISP blocks ports 80/443 (residential connection)
  • Need HTTPS for mobile app connectivity (Audiobookshelf, Immich, Paperless-ngx, etc.)

Pattern Overview

Internet → Router (port forward) → nginx (SSL) → Docker service (internal port)
                 ↑
          DuckDNS (IP updater)
          Let's Encrypt (DNS challenge, no port 80 needed)

Step 1: DuckDNS Setup

# Create updater script
sudo mkdir -p /opt/duckdns
sudo tee /opt/duckdns/update.sh << 'EOF' > /dev/null
#!/bin/bash
echo url="https://www.duckdns.org/update?domains=<SUBDOMAIN>&token=<TOKEN>&ip=" | curl -k -s -o /opt/duckdns/duck.log -K -
EOF
sudo chmod +x /opt/duckdns/update.sh

# Run once to verify (should return "OK")
sudo /opt/duckdns/update.sh && cat /opt/duckdns/duck.log

# Add cron (every 5 min)
(sudo crontab -l 2>/dev/null | grep -v duckdns; echo "*/5 * * * * /opt/duckdns/update.sh") | sudo crontab -

Verify: dig +short <domain>.duckdns.org should return your public IP.

Step 2: Docker Service Setup

The service must run on a port that doesn't conflict with nginx (which will take 80 and the SSL port). Common pattern: use PORT=<high-port> env var or map -p <external>:<internal>.

Audiobookshelf example:

services:
  audiobookshelf:
    image: ghcr.io/advplyr/audiobookshelf:latest
    network_mode: host
    environment:
      - PORT=13378
      - TZ=America/New_York
    volumes:
      - ./config:/config
      - ./metadata:/metadata
      - /path/to/audiobooks:/audiobooks:ro

Immich example: Already runs on 2283. Add IMMICH_EXTERNAL_DOMAIN=<domain>:<port> to .env so shared links use the correct URL.

Paperless-ngx example: Map 8010:8000 and set PAPERLESS_URL=https://<domain>:<port>.

Step 3: Let's Encrypt via DNS Challenge

When ports 80/443 are blocked, use DNS-01 challenge:

# Install DuckDNS plugin
sudo pip3 install --break-system-packages certbot-dns-duckdns

# Create credentials file
sudo mkdir -p /etc/letsencrypt
sudo tee /etc/letsencrypt/duckdns.ini << 'EOF' > /dev/null
dns_duckdns_token=<YOUR_DUCK_DNS_TOKEN>
EOF
sudo chmod 600 /etc/letsencrypt/duckdns.ini

# Get certificate
sudo certbot certonly \
  --authenticator dns-duckdns \
  --dns-duckdns-credentials /etc/letsencrypt/duckdns.ini \
  --dns-duckdns-propagation-seconds 30 \
  -d <domain>.duckdns.org \
  --non-interactive --agree-tos --email <your-email>

Cert auto-renews via certbot's systemd timer.

Step 4: nginx Configuration

server {
    listen <PORT> ssl;
    server_name <domain>.duckdns.org;

    ssl_certificate /etc/letsencrypt/live/<domain>.duckdns.org/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/<domain>.duckdns.org/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    client_max_body_size 500M;

    location / {
        proxy_pass http://127.0.0.1:<SERVICE_PORT>;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        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;
        proxy_read_timeout 86400;
    }
}

# Optional: redirect HTTP → HTTPS on same port
server {
    listen 80;
    server_name <domain>.duckdns.org;
    return 301 https://$host:<PORT>$request_uri;
}

Deploy:

sudo cp /tmp/<service>-nginx.conf /etc/nginx/sites-available/<service>
sudo ln -sf /etc/nginx/sites-available/<service> /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Step 5: Router Port Forwarding (ASUS RT-AX82U)

  1. http://192.168.50.1 → log in
  2. WAN → Port Forwarding tab
  3. Add Profile:
    • Service Name: <service>
    • Protocol: TCP
    • External Port: <PORT>
    • Internal IP: 192.168.50.98
    • Internal Port: <PORT>
  4. OK → Apply

Port assignments for this server:

Service External Port Internal
Audiobookshelf 3443 13378
Immich 3444 2283
Paperless-ngx 3446 8010

Pitfalls

  • network_mode: host + port conflicts: Audiobookshelf defaults to port 80 internally. Set PORT=<alt> env var to avoid nginx conflict.
  • Shell token mangling: Plex/Audiobookshelf tokens containing special chars get garbled by bash. Use Python to make HTTP calls instead: urllib.request.urlopen(f"http://localhost:32400/library/sections?X-Plex-Token={token}").
  • Hairpin NAT: Testing https://<domain>:<port> from within the LAN may fail. Test from cellular data to confirm external access works.
  • Heimdall uses 8443: If Heimdall runs on the same machine, port 8443 is taken. Use 3443+ range.
  • Port 8000 is Portainer: Paperless-ngx defaults to 8000 — map to 8010 instead.
  • Immich needs IMMICH_EXTERNAL_DOMAIN: Without it, shared links use localhost:2283. Set in .env and restart with docker compose up -d (NOT restart).
  • Paperless-ngx needs PAPERLESS_URL: Same issue — set the full https://<domain>:<port> URL.
  • Cert name must match domain: The cert is issued for the full DuckDNS domain. All nginx server blocks for different ports on the same domain reuse the same cert files.