Adding Services Beside an Existing Bot
Prefer one Compose project per independently operated application:
/srv/discord-bot/{compose.yaml,.env,data/}
/srv/rsshub/{compose.yaml,.env}
/srv/example-api/{compose.yaml,data/}
/srv/caddy/{compose.yaml,Caddyfile}
Compose normally derives the project name from the directory, isolating resource names and default networks. A top-level name: can make it explicit. Separate projects let a Bot update without recreating RSSHub; combine components only when they genuinely share deployment and rollback.
A Discord Gateway Bot usually initiates an outbound WebSocket and needs no published inbound port:
name: discord-bot
services:
bot:
image: ghcr.io/example/discord-bot:<pinned-version>
restart: unless-stopped
env_file: [.env]
volumes: [./data:/app/data]
Bots receiving HTTP interactions or webhooks do need an inbound route. For public web apps, prefer Caddy as the single 80/443 entry instead of publishing arbitrary application ports. Admin-only services can bind to loopback or Tailscale. Databases and caches should remain on internal project networks.
For cross-project proxying, create an explicit external network such as docker network create ingress, declare it external: true, and attach only proxy-facing services.
A validated shared-ingress pattern
Phases 2 and 3 on this VPS put the general model into practice:
Internet -> host TCP 80/443 -> Caddy -> infra-edge -> RSSHub:1200
WSL2 -> Tailscale SSH -> VPS 127.0.0.1:3001 -> Uptime Kuma
Caddy is the only Docker service published on public host addresses. The reusable external bridge is named infra-edge; Caddy and RSSHub belong to it, and Caddy reaches RSSHub through the rsshub-prod alias. RSSHub publishes no host port. Uptime Kuma deliberately stays outside the public ingress path and publishes exactly 127.0.0.1:3001:3001.
Open the monitoring UI with a local-forward tunnel instead of adding a public route:
ssh -i "$LOCAL_SSH_PRIVATE_KEY" \
-o IdentitiesOnly=yes \
-L 3001:127.0.0.1:3001 \
"$VPS_USER@$TAILSCALE_IPV4"
Then browse to http://127.0.0.1:3001. The tunnel protects the management route without publishing credentials, cookies, or the administrator identity.
UFW is not the Docker exposure audit
An active UFW ruleset is necessary, but it is not proof that a Docker-published port is private. Docker's official firewall documentation explains that published container traffic is diverted before the chains UFW normally uses. The binding in Compose is therefore part of the security boundary: include 127.0.0.1 explicitly for a loopback-only service, and verify the result from Docker, the host, and another machine.
Check the Engine version before reusing this isolation pattern. Docker documents that in releases older than 28.0.0, hosts on the same layer-2 network segment could reach ports published to localhost. On those releases, a 127.0.0.1 binding alone is not sufficient evidence of isolation.
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}\t{{.Status}}'
sudo ss -lntup
sudo ufw status verbose
sudo ufw status numbered
docker network inspect infra-edge
KUMA_ID=$(docker compose -f /srv/uptime-kuma/compose.yaml ps -q uptime-kuma)
docker inspect "$KUMA_ID" --format '{{json .HostConfig.PortBindings}}'
RSSHUB_ID=$(docker compose -f /srv/rsshub/compose.yaml ps -q rsshub)
docker inspect "$RSSHUB_ID" --format '{{json .HostConfig.PortBindings}}'
The accepted Kuma inspection value is:
{"3001/tcp":[{"HostIp":"127.0.0.1","HostPort":"3001"}]}
The accepted RSSHub binding map is {}. A bare 1200/tcp in the image port list is internal, not a host publication.
443/udp or 2019/tcp in a Caddy image's displayed port list does not by itself mean that the host publishes those ports. Treat it as exposure only when the Compose mapping, Docker inspection, or host sockets show a host binding.
From WSL2, verify both the allowed and denied paths. A public-IP response on port 3001 or a successful public-IP SSH connection fails acceptance.
curl --fail https://infra.example.com/healthz
curl --connect-timeout 5 --max-time 7 http://"$VPS_PUBLIC_IPV4":3001/
curl --connect-timeout 5 --max-time 7 http://"$VPS_PUBLIC_IPV4":1200/
curl --connect-timeout 5 --max-time 7 http://"$TAILSCALE_IPV4":1200/
ssh -o ConnectionAttempts=1 -o ConnectTimeout=5 \
"$VPS_USER@$VPS_PUBLIC_IPV4"
Phase 2 used one deliberate full reboot to prove the host baseline. A newly added application should first receive a smaller controlled restart: restart only that Compose service, wait for health, then repeat endpoint, access-control, Docker-binding, and dependency checks. Reboot the whole host only when a later task specifically needs host-level recovery evidence.
Treat DISCORD_TOKEN as a secret: keep it in a mode-600 ignored .env or a secret manager, never commit or paste expanded Compose output, and rotate it in the developer portal after suspected exposure.
Repeatable addition workflow
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
docker network ls; docker volume ls
ss -lntup; free -h; df -h /
mkdir -p /srv/new-service/data
cd /srv/new-service
docker compose config --quiet
docker compose config --services
docker compose config --images
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail 100
docker compose config may expand secrets, so do not share its full output. Pin versions or digests rather than blindly relying on latest. A running container is not a health check: verify that the Bot connects and responds, or that the web service works through Caddy and writes to the expected volume.
Record the old image and Compose diff before updates. To update one service:
cd /srv/discord-bot
docker compose pull bot
docker compose up -d --no-deps bot
docker compose ps
docker compose logs --tail 100 bot
Check dependency and database-migration requirements before using --no-deps. Avoid running docker compose down from an ambiguous directory and never use docker system prune -a as routine troubleshooting. Clear ownership boundaries matter more than saving a few YAML files.
The RSSHub shadow deployment is the concrete example of this workflow: official digest-pinned image, one protected env file, memory cache, infra-edge, no host port, authenticated canary, and a bounded rollback.