Skip to main content

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

ComponentResponsibilityExplicit non-responsibility
Authoritative DNSResolve the RSS subdomain to the VPSDoes not proxy application traffic or manage containers
CaddyOwn public 80/443, TLS, redirects, and proxyingDoes not store the raw RSSHub ACCESS_KEY
infra-edgeGive Caddy and RSSHub an internal path and service discoveryDoes not publish port 1200 to the host or Internet
RSSHubFetch sources, produce RSS, and protect real routesDoes not store consumer data
Uptime KumaCheck public /healthz without a keyContains no ACCESS_KEY and exposes no public management UI
Local RSSHubContinue serving the current feed consumerIs 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:

[REMOTE: 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:

[LOCAL: 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:

[REMOTE: VPS]
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:

  1. Restore Caddyfile from /srv/caddy/Caddyfile.pre-rsshub-p3.
  2. Validate the complete restored Caddy configuration.
  3. Reload Caddy without restarting its container.
  4. Run docker compose -f /srv/rsshub/compose.yaml down.
  5. Keep /srv/rsshub/compose.yaml and the protected .env.
  6. 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.