RSSHub Production Shadow Deployment
What this phase proves
RSSHub is the VPS's first real application workload. It has a public HTTPS name, a working real route, access control, and monitoring, but it remains a shadow deployment because no production consumer has moved:
Public canary
`-- HTTPS -> Caddy -> infra-edge -> VPS RSSHub
existing feed consumer
`-- local RSSHub -> existing production path and fallback
This proves the server path before Phase 4 changes the consumer. A failed shadow instance can be removed from ingress without interrupting the existing consumer.
Responsibility map
| Component | Responsibility | Explicit non-responsibility |
|---|---|---|
| Authoritative DNS | Resolve the RSS subdomain to the VPS | Does not proxy application traffic or manage containers |
| Caddy | Own public 80/443, TLS, redirects, and proxying | Does not store the raw RSSHub ACCESS_KEY |
infra-edge | Give Caddy and RSSHub an internal path and service discovery | Does not publish port 1200 to the host or Internet |
| RSSHub | Fetch sources, produce RSS, and protect real routes | Does not store consumer data |
| Uptime Kuma | Check public /healthz without a key | Contains no ACCESS_KEY and exposes no public management UI |
| Local RSSHub | Continue serving the current feed consumer | Is not stopped or removed in Phase 3 |
The public route is:
rss.example.com
|
v
Caddy :80/:443
|
v
infra-edge
|
v
rsshub-prod:1200
Only Caddy publishes host ports. RSSHub's 1200/tcp exists inside Docker; neither the public nor Tailscale host address accepts a direct connection to it.
Why the deployment stays small
The representative Bilibili route already returned valid RSS and non-zero items on the local RSSHub without Redis, Browserless, or Chromium. The VPS therefore starts with the same minimum:
- the official RSSHub image pinned by immutable digest;
CACHE_TYPE=memory;- no Redis;
- no Browserless or Chromium;
- no database or application data volume.
An optional service appearing in an upstream example is not evidence that a personal single-node workload needs it. Add one only after a required route proves the dependency.
Files and secret boundary
/srv/rsshub/
├── compose.yaml # reviewable image, health, network, and restart policy
└── .env # mode 600; never committed or expanded in reports
The env file contains only the required variables: a newly generated ACCESS_KEY and the UID/cookie pair required by the representative Bilibili route. Variable names may be documented; values must not enter chat, Git, the website, or shared Compose output.
The important Compose shape is deliberately small:
services:
rsshub:
image: diygod/rsshub@sha256:<immutable-digest>
restart: unless-stopped
env_file: [.env]
environment:
NODE_ENV: production
CACHE_TYPE: memory
DISALLOW_ROBOT: "1"
networks:
infra-edge:
aliases: [rsshub-prod]
networks:
infra-edge:
external: true
There is intentionally no ports:, host networking, privileged mode, Docker socket, database, or persistent application volume.
Health and access control are different jobs
Real RSS routes require a valid ACCESS_KEY and return 403 without it. Monitoring must remain keyless so Kuma does not store a high-value credential.
The current RSSHub release also protects /healthz when ACCESS_KEY is set. This deployment does not copy the raw key into Caddy. Instead, it uses RSSHub's route-bound derived code: the container healthcheck computes it at runtime, and Caddy injects only the code valid for /healthz.
GET /healthz -> 200, caller supplies no key
GET /real/rss/route -> 403
GET /real/rss/route?key=<valid-key> -> 200
The public health request still traverses Caddy, infra-edge, and RSSHub; Caddy does not fabricate a static response. The derived code value is not published either.
Routine checks
On the VPS:
cd /srv/rsshub
docker compose ps
docker compose logs --tail 100 rsshub
RSSHUB_ID=$(docker compose ps -q rsshub)
docker inspect "$RSSHUB_ID" \
--format '{{.State.Status}} {{.State.Health.Status}} {{json .HostConfig.PortBindings}}'
docker network inspect infra-edge
The accepted result is running healthy, a binding map of {}, and both Caddy and RSSHub on infra-edge.
From WSL2:
curl --fail https://rss.example.com/healthz
curl -I http://rss.example.com/healthz
# Both must time out or refuse; neither may return RSSHub content.
curl --connect-timeout 5 --max-time 7 \
"http://$VPS_PUBLIC_IPV4:1200/"
curl --connect-timeout 5 --max-time 7 \
"http://$TAILSCALE_IPV4:1200/"
A real-route check should report only status, feed format, channel-identity match, and item count. Never paste a full URL containing the key, private UID, or cookie into logs or notes.
Controlled restart
Phase 3 does not reboot the whole VPS. Restart only the new application:
cd /srv/rsshub
docker compose restart rsshub
docker compose ps
After health returns, recheck public health, unauthenticated 403, the authenticated feed, Caddy, Kuma, Tailscale SSH, and public-SSH blocking. Caddy and Kuma container IDs and start times should not change during an RSSHub-only restart.
Rollback
A healthy deployment does not need a destructive rollback demonstration. The mechanical sequence is:
- Restore Caddyfile from
/srv/caddy/Caddyfile.pre-rsshub-p3. - Validate the complete restored Caddy configuration.
- Reload Caddy without restarting its container.
- Run
docker compose -f /srv/rsshub/compose.yaml down. - Keep
/srv/rsshub/compose.yamland the protected.env. - Leave the local RSSHub running; remove DNS later only if desired.
Never use docker system prune -a, volume prune, or network prune for this rollback.
What has not happened
Phase 3 did not change the feed consumer's production endpoint, persistent data, scheduler, or source configuration, and it did not stop the local RSSHub. Phase 4 must treat consumer cutover, persistent-data backup and restore, scheduler migration, and fallback retirement as one controlled change.