---
name: self-hosted-services
description: "Install, configure, and expose self-hosted services via Docker — n8n, databases, media servers, and similar. Covers Docker installation, volume management, networking between containers, and HTTPS via Caddy reverse proxy. User prefers simple over complex, runs on Linux."
version: 1.2.0
author: Hermes Agent
platforms: [linux]
tags: [docker, self-hosted, reverse-proxy, caddy, n8n, networking]
---

# Self-Hosted Services

Install and expose self-hosted services on Linux via Docker + Caddy reverse proxy.

## Docker Basics

### Check Docker
```bash
docker --version && docker ps
```

### Run container
```bash
# Basic
docker run -d --name <name> -p <host-port>:<container-port> <image>

# With named volume (persistent — preferred over host paths)
docker volume create <volume-name>
docker run -d --name <name> -p <host-port>:<container-port> -v <volume-name>:</container/path> <image>
```

### Manage containers
```bash
docker ps -a                    # list all (including stopped)
docker kill <name>             # force stop
docker rm <name>               # remove (must be stopped first)
docker rm -f <name>            # kill + remove in one go
docker logs <name> --tail 20  # check logs
```

### Permission denied on Docker commands
If `docker stop/kill/restart` fails with "permission denied":
- Try `docker rm -f <name>` (force remove without stopping)
- If that also fails: `docker kill <name> && sleep 1 && docker rm <name>`
- No sudo available → cannot stop/start/restart containers; inform user

## Networking: Bridge Containers to Reverse Proxy

When a reverse proxy needs to reach a service container, both must be on the same Docker network:

```bash
docker network create <network-name>
docker network connect <network-name> <service-container>
docker network connect <network-name> <reverse-proxy-container>
```

**Caddy in bridge mode can't reach other containers via `localhost`** — must use container name.

### ⚠️ UFW blocks Docker EXPOSE ports

Docker `EXPOSE` is documentation only. UFW still blocks incoming connections.

```bash
# Check UFW status
sudo ufw status verbose

# Open port for LAN access
sudo ufw allow from 192.168.1.0/24 to any port <port> proto tcp
```

## HTTPS via Caddy

### Caddy as Docker container
```bash
docker run -d --name caddy -p 80:80 -p 443:443 --network <net> \
  -v /path/to/Caddyfile:/etc/caddy/Caddyfile caddy:2
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
```

### Caddy as host systemd service
- Config: `/etc/caddy/Caddyfile`
- Reload: `sudo systemctl reload caddy`
- **Admin API note:** systemd Caddy does NOT listen on port 2019 (only `caddy run` foreground mode does). Cannot remote-reload without sudo.

### HTTPS for subdomain (Let's Encrypt — automatic)
```caddy
subdomain.example.duckdns.org:443 {
    reverse_proxy <container-name>:<port>
}
```

### HTTPS for IP address (self-signed, browsers warn)
Let's Encrypt CANNOT issue certs for raw IPs. Use self-signed:

```caddy
:443 {
    tls /etc/caddy/cert.pem /etc/caddy/key.pem
    reverse_proxy <container-name>:<port>
}
```

Generate cert:
```bash
openssl req -x509 -newkey rsa:4096 \
  -keyout /path/to/key.pem -out /path/to/cert.pem \
  -days 365 -nodes -subj "/CN=<your-ip>"
### ⚠️ Chrome 无法连接但 Safari 正常（HTTP/3 问题）

**症状：** `curl -k https://localhost` 正常返回 n8n 页面，Safari 打开 `https://192.168.1.x` 正常，但 Chrome 报 `ERR_ADDRESS_UNREACHABLE`。

**根因：** Chrome 优先尝试 HTTP/3 (QUIC/UDP:443)。局域网内 UDP 通常被交换机/路由器过滤，导致 Chrome 直接不可达。Safari 没有 QUIC 优先行为，走 HTTP/2 正常。

**诊断：** `curl -sk https://<ip> -I | grep alt-svc` — 有 `h3=":443"` 确认 Caddy 开启了 HTTP/3。

**解法：** 关闭 Caddy 的 HTTP/3。在 Caddyfile 顶部加全局选项：
```caddy
{
  http3 off
}

:443 {
    tls /etc/caddy/cert.pem /etc/caddy/key.pem
    reverse_proxy n8n-v2:5678
}
```

**Caddy 重载（systemd 模式，无 sudo）：** 用户手动执行：
```bash
sudo cp /home/kuhnn/Caddyfile /etc/caddy/Caddyfile && sudo systemctl reload caddy
```

**注意：** Caddy 系统服务（`caddy run --config /etc/caddy/Caddyfile`）的 admin API 默认不监听 2019，只能靠 systemctl reload。
docker volume create n8n-data
docker run -d --name n8n -p 5678:5678 \
  -v n8n-data:/home/node/.n8n \
  n8nio/n8n
```

### n8n secure cookie error
```
🚫 Your n8n server is configured to use a secure cookie,
however you are either visiting this via an insecure URL,
or using Safari.
```

**Cause:** n8n requires HTTPS to write secure cookies. HTTP or self-signed HTTPS triggers this.

**Fix options:**
1. **HTTPS via Caddy** — reverse proxy with HTTPS → n8n sees HTTPS, cookie works
2. **`N8N_SECURE_COOKIE=false`** — disables the check (needs container restart)
3. **Use Chrome on LAN** — Chrome accepts self-signed HTTP/2; Safari does not

**Chrome vs Safari on LAN with self-signed HTTPS:**
- Chrome: may fail with `ERR_ADDRESS_UNREACHABLE` if HTTP/3 is enabled (UDP blocked)
- Safari: works fine
- Fix: disable HTTP/3 in Caddy (see above)

### Access n8n
- Direct HTTP: `http://localhost:5678` (Chrome/Edge ok, Safari may block)
- Via Caddy HTTPS: `https://<host-ip>` (requires HTTP/3 disabled or cert trusted)
- Via domain + Let's Encrypt: `https://your-domain.duckdns.org` (recommended)

## Troubleshooting

### 502 Bad Gateway from Caddy
- Backend container running? `docker ps`
- Same Docker network? `docker network inspect <net>`
- `reverse_proxy` target should be container name, not `localhost`

### Container can't write to mounted volume
- Use named volume instead of host path — Docker manages permissions automatically

### Port already allocated
```bash
ss -tlnp | grep :<port>
docker kill <container-holding-port> && sleep 1 && docker rm <container-holding-port>
```

### Caddy cert file not found
Caddyfile paths are relative to container filesystem. Copy certs into container first:
```bash
docker cp host/cert.pem caddy:/etc/caddy/cert.pem
docker cp host/key.pem caddy:/etc/caddy/key.pem
```

## Tailscale Funnel (HTTPS for external access)

Exposes local services to internet over HTTPS — no router config, no domain needed.

```bash
# One-time auth
~/.local/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false
# → send URL to user, they click to authorize

~/.local/bin/tailscale --socket=/tmp/tailscaled.sock funnel enable
# → send URL to user, they click to authorize

# Serve
~/.local/bin/tailscale --socket=/tmp/tailscaled.sock serve http://localhost:<port>
~/.local/bin/tailscale --socket=/tmp/tailscaled.sock funnel on

# Status
~/.local/bin/tailscale --socket=/tmp/tailscaled.sock funnel status
```

Access: `https://<device>.ts.net/<path>` — Tailscale auto-provisions SSL.

### Userspace mode (`--tun=userspace-networking`)
- No `tailscale0` interface — use `tailscale --socket=/tmp/tailscaled.sock status --json`
- No root needed
- Tailscale IP unreachable from host itself (expected)

### ⚠️ Funnel 在 userspace-networking 模式下无法提供公网访问（致命限制）

**症状：** Funnel 状态显示 "Available on the internet"，外部访问却 SSL 超时。

**根因（GitHub Issue #12788）：** Funnel 依赖 `tailscale cert` 向 Let's Encrypt 申请 SSL 证书。`tailscale cert` 需要修改系统 DNS 设置，**需要 root 权限**。userspace 模式以非 root 用户运行，无法完成证书申请，导致 SSL 握手失败（`SSL_ERROR_INTERNAL_ERROR_ALERT`）。

```
tailscale cert <domain>
500 Internal Server Error: acme.GetReg: Get "https://acme-v02.api.letsencrypt.org/directory":
  dial tcp: lookup acme-v02.api.letsencrypt.org on [::1]:53: connection refused
```

**判断方法：**
```bash
# 从外部网络测试（必须）
curl -v --connect-timeout 5 https://<device>.tail95fef0.ts.net
# SSL handshake timeout = 证书获取失败的典型症状
```

**解法：** 切换到 TUN 模式（需要 root）：
```bash
# 停止 userspace tailscaled
pkill tailscaled

# 以 TUN 模式启动（需要 root）
sudo tailscaled --socket=/tmp/tailscaled.sock

# 启用 Funnel
tailscale --socket=/tmp/tailscaled.sock up
tailscale --socket=/tmp/tailscaled.sock funnel --bg 5679
```

**替代方案（无 root）：** cloudflared tunnel、ngrok、frp

### ⚠️ Funnel + Docker Caddy 端口冲突 (关键陷阱)

**症状：** Funnel 配置显示 `AllowFunnel: true`，但外部流量返回的是另一个服务（如 Synology Web Station）。

**根因：** Docker Caddy 容器已绑定 `0.0.0.0:443`。Funnel 在 userspace 模式下声称占用 443，但 Docker 的端口映射优先级更高，外部流量被 Caddy 截获。

**诊断：**
```bash
# 1. 检查谁在占用 443
ss -tlnp | grep :443

# 2. 检查 Funnel 真实状态
tailscale --socket=/tmp/tailscaled.sock funnel status --json 2>/dev/null | python3 -c "
import sys, json
d = json.load(sys.stdin)
print('AllowFunnel:', d.get('AllowFunnel', {}))
print('TCP:', d.get('TCP', {}))
"

# 3. 外部访问测试（从另一台机器）
curl -k https://<funnel-url> --connect-timeout 5

# 4. 验证 Funnel 真正在监听
ss -tlnp | grep tailscaled  # userspace 模式不会出现在这里
```

**解法：**
1. **停止 Caddy 容器**（需要 sudo）：`sudo docker stop caddy`
2. 或修改 Caddy 端口映射，将 443 改为其他端口
3. Funnel 重新接管 443 后验证：`ss -tlnp | grep :443` 应只有 tailscaled

**关键区别：** userspace 模式下 Funnel 的 `AllowFunnel: true` 是**配置声明**，不是实际端口监听证明。真正的验证是外部流量能否到达 Funnel。

### Funnel DNS requirement
External users need to resolve `.ts.net` domain. Configure systemd-resolved:
```bash
sudo resolvectl dns tailscale0 100.100.100.100
sudo resolvectl domain tailscale0 ~<tailnet>.ts.net
```

### sudo can't find tailscaled
`sudo` PATH doesn't include `~/.local/bin`. Use full path:
```bash
sudo /home/kuhnn/.local/bin/tailscaled ...
```

## Reference
- `references/n8n-docker-setup.md` — n8n installation log
- `references/self-signed-https-guide.md` — self-signed HTTPS walkthrough
- `references/n8n-https-caddy-guide.md` — n8n secure cookie / Caddy / Let's Encrypt 完整方案
- `references/tailscale-funnel-port-conflict.md` — Funnel + Docker Caddy 端口冲突（userspace 模式下 Funnel 被 Docker 截获）
- `references/tailscale-funnel-debug.md` — Funnel DNS / systemd-resolved debugging
- `references/tailscale-funnel-userspace-ssl-failure.md` — Funnel 在 userspace 模式下 SSL 证书无法获取的致命限制（含 GitHub Issue #12788 证据）
- `references/24x7-server-hardening.md` — 24/7 server checklist: power, sleep, firewall, swap
## User Preferences

- Prefers Docker over npm/system installation
- Prefers simple over complex
- On Linux (Ubuntu 22.04)
- **No sudo password** — cannot stop/start Docker containers, cannot systemctl reload without user manually running the command
- **Screen saver preserved** — when disabling sleep/hibernate via `systemd mask`, display managers (xscreensaver, light-locker) are unaffected since they run in user session space

### Docker Container Cannot Stop (Permission Denied)

Symptom: `sudo docker stop <container>` returns "permission denied".

Root cause: container process held by another tool, or Docker daemon permission issue.

Fix (try in order):
```bash
# 1. Force remove
docker rm -f <container-name>

# 2. If that fails, kill the process directly
sudo kill -9 $(sudo docker inspect <container> --format '{{.State.Pid}}')
sleep 1
sudo docker rm <container>

# 3. Check who holds the process
ps aux | grep <container-name>
```

### smartmontools Service Name

Enable smartd using the package name, not the daemon name:
```bash
sudo systemctl enable smartmontools   # ✓ correct
sudo systemctl enable smartd.service # ✗ fails ("Refusing to operate on alias name")
sudo systemctl start smartmontools
```

### logrotate Is Triggered by Timer

`logrotate.service` is static (triggered by `logrotate.timer`), not a daemon. Verify:
```bash
systemctl status logrotate        # shows inactive (dead), has TriggeredBy: logrotate.timer
systemctl status logrotate.timer  # should be active (waiting)
```

No need to `enable` it — the timer is enabled by default.

### power-profiles-daemon (Ubuntu 22.04)

Ubuntu's built-in power management service (not tlp). Like thermald, it's a persistent service:
```bash
systemctl status power-profiles-daemon.service
powerprofilesctl list
sudo powerprofilesctl set performance
```
