159 lines
5.4 KiB
Markdown
159 lines
5.4 KiB
Markdown
# 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=<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:**
|
|
```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=<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:
|
|
|
|
```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=<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
|
|
|
|
```nginx
|
|
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:
|
|
```bash
|
|
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.
|