Skip to main content

Adding Services Beside an Existing Bot

Suppose a VPS already runs a Discord Bot and you want to add RSSHub, a personal API, or a website. The main question is how to keep each service's configuration, lifecycle, network, and data boundaries clear, beyond Docker's ability to run one more container.

Prefer one Compose project per application​

For example, keep each application in its own directory:

  • /srv/discord-bot/: compose.yaml, a Git-ignored .env, and data/.
  • /srv/rsshub/: compose.yaml and .env.
  • /srv/example-api/: compose.yaml and data/.
  • /srv/caddy/: compose.yaml and Caddyfile.

Compose normally derives the project name from the directory, grouping its containers, default network, and resource names while isolating resources and default networks between projects. You can also set name: discord-bot at the top level of the Compose file. With separate projects, updating the Bot does not recreate RSSHub, and the scope of logs and rollback stays clear.

A single large compose.yaml can be appropriate, but it ties unrelated services to the same lifecycle. For a personal server on one machine, separate projects are usually an easier starting point.

First decide whether the service needs an inbound port​

A Discord Gateway Bot usually initiates a persistent outbound WebSocket connection to Discord. A Bot using only the Gateway generally needs no port open on the VPS's public address:

/srv/discord-bot/compose.yaml
name: discord-bot

services:
bot:
image: ghcr.io/example/discord-bot:<pinned-version>
restart: unless-stopped
env_file:
- .env
volumes:
- ./data:/app/data

A Bot receiving HTTP interactions or webhooks needs an inbound route; a management panel may also need inbound HTTP. In that case, prefer Caddy as the only public entry point rather than publishing arbitrary ports for each container.

Three kinds of network entry point​

Service typePreferred entry pointExample
Outbound-only worker/BotNo ports entryDiscord Gateway Bot, scheduled scraper
Administrator access onlyBind to loopback or Tailscale127.0.0.1:3000:3000, or tailnet only
Public website/APICaddy reverse proxy on 80/443Do not expose the application port directly

When Caddy needs to reach an application in another Compose project, create an explicitly named external Docker network:

docker network create ingress

Each project declares that network:

networks:
ingress:
external: true

Attach only services that the proxy needs to reach to ingress. Databases and caches should stay on their own projects' internal networks, with no database ports published to the Internet.

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.

ReachabilityAccepted host port
PublicTCP 80 and TCP 443, owned by Caddy
Tailnet onlyTCP 22 through tailscale0
VPS loopback onlyTCP 3001, owned by Uptime Kuma
Docker network onlyRSSHub TCP 1200, not published on the host
Everything elseNo Docker host publication

Open the monitoring UI through a local-forward tunnel, keeping it off the public ingress path:

[LOCAL: WSL2]
ssh -i "$HOME/.ssh/id_ed25519" \
-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.

[REMOTE: VPS]
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 {}. If docker ps shows only 1200/tcp, that indicates an internal container port, 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 inspect, or host sockets show a HostIP/HostPort 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.

[LOCAL: WSL2]
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.

Secrets and configuration​

A Discord Bot token is a high-value secret:

  • Store it in a permission-restricted .env or a suitable secrets manager.
  • Add .env to .gitignore and never commit it.
  • Do not expose expanded values through docker compose config, screenshots, or shared logs.
  • If you suspect exposure, rotate the token in the Discord Developer Portal; deleting it from a file is not enough.

For example:

cd /srv/discord-bot
touch .env
chmod 600 .env

In notes, record only variable names, such as DISCORD_TOKEN, never their actual values.

Repeatable addition workflow​

1. Inventory resources and conflicts​

docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
docker network ls
docker volume ls
ss -lntup
free -h
df -h /

Check that the required ports are free, memory and disk have headroom, and the new service's data location is known.

2. Create a separate directory​

# First create /srv/new-service using the runbook's guarded procedure.
# Create and review compose.yaml and any required secret files there.
cd /srv/new-service || exit 1
test -f compose.yaml || exit 1
mkdir -p data

3. Review the resolved Compose configuration​

docker compose -f ./compose.yaml config --quiet
docker compose config --services
docker compose config --images

docker compose config may expand environment variables. Do not copy its full output to public channels.

4. Pull and start​

docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail 100

For production, prefer pinning an explicit version or digest rather than blindly relying on latest. An image update may introduce incompatible changes.

5. Verify the service's own health​

A container marked running only shows that its main process has not exited. Also verify the application's behavior: the Bot should connect to the Gateway and answer a test command, an HTTP service should return the expected content through Caddy, and data should be written to the expected volume.

6. Record a rollback point​

Before an update, record the current image version and Compose diff. Rollback usually means restoring the old version, running docker compose up -d again, and restoring compatible data. If a migration changed the database format, reverting the image alone may not be enough.

Operate each project separately​

cd /srv/discord-bot
docker compose ps
docker compose logs -f --tail 100
docker compose pull
docker compose up -d

cd /srv/rsshub
docker compose ps

From any directory, you can also use:

docker compose -f /srv/discord-bot/compose.yaml ps

From the top-level /srv directory or any directory where the target project is unclear, do not casually run docker compose down. Do not use docker system prune -a for routine disk or deployment problems. The former may target the wrong project; the latter performs a broad cleanup.

Update one service without touching the others​

cd /srv/discord-bot
docker compose pull bot
docker compose up -d --no-deps bot
docker compose ps
docker compose logs --tail 100 bot

This recreates only the named service. Still check dependencies, database migrations, and release notes individually: --no-deps is not a universal safety switch.

When to consider combining projects​

Combine two components in one Compose project only when they share a release cycle, must start and roll back together, and belong to the same configuration. An application and its dedicated worker can share a project; a Discord Bot and an independent RSSHub usually do not need to.

As a server grows, clear boundaries save more work than reducing the number of YAML files. Many containers are manageable; not knowing which command restarts which service is the real problem.

The RSSHub shadow deployment is a concrete example from this server: an official digest-pinned image, one protected env file, memory cache, infra-edge, no host port, an authenticated canary, and bounded rollback steps.

Explore connectionsOpen network