# 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 ```bash # 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=&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 .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=` env var or map `-p :`. **Audiobookshelf example:** ```yaml 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=:` to .env so shared links use the correct URL. **Paperless-ngx example:** Map `8010:8000` and set `PAPERLESS_URL=https://:`. ## Step 3: Let's Encrypt via DNS Challenge When ports 80/443 are blocked, use DNS-01 challenge: ```bash # 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= 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 .duckdns.org \ --non-interactive --agree-tos --email ``` Cert auto-renews via certbot's systemd timer. ## Step 4: nginx Configuration ```nginx server { listen ssl; server_name .duckdns.org; ssl_certificate /etc/letsencrypt/live/.duckdns.org/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/.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:; 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 .duckdns.org; return 301 https://$host:$request_uri; } ``` Deploy: ```bash sudo cp /tmp/-nginx.conf /etc/nginx/sites-available/ sudo ln -sf /etc/nginx/sites-available/ /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: `` - Protocol: TCP - External Port: `` - Internal IP: `192.168.50.98` - Internal 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=` 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://:` 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://:` 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.