Originally published on kuryzhev.cloud
Context: Why We Built a Discord Bot for Our Game Server Fleet
We had six to ten game server containers running on a single Docker host — Valheim, a couple of Minecraft worlds, an ARK server that our community treated like a second job. Every time something hiccuped, the same message showed up in Discord: "is the server down?" Someone would ping me, I'd SSH in, check docker ps, restart the container, and post a thumbs up. That got old fast.
The plan seemed obvious: build a discord bot docker restart integration so trusted players could run /restart valheim or /status ark without anyone needing shell access. I threw together a prototype in an afternoon using discord.py 2.3.2 and docker-py 6.1.3, demoed it in our test guild, everyone loved it. Then it went to production and broke in three separate ways over the following months — each one embarrassing in its own way, and each one teaching us something we should have known before shipping.
This is the honest version of that story: what we got wrong, why it seemed fine at first, and what the setup looks like now that it's actually safe to hand to non-admins.
Mistake #1: We Mounted docker.sock Directly Into the Bot Container
The fastest way to give a container control over Docker is to mount the socket: -v /var/run/docker.sock:/var/run/docker.sock. It's one line, it works immediately, and that's exactly the problem. I added it to the bot's compose service without thinking twice, because in my head it was "just letting the bot talk to Docker for its own containers."
It's not scoped to "its own containers." Mounting docker.sock is functionally equivalent to giving that process passwordless root on the host. Anyone who compromises the bot process — a vulnerable dependency in discord.py, a bug in our own command parsing, a leaked token letting someone run arbitrary commands through Discord — gets full control of every container on that host, plus the ability to spin up new privileged containers that mount the host filesystem. I wasn't building a "restart my Valheim server" feature. I was building a root shell with a Discord frontend.
Watch out for this pattern anywhere you see it, not just in bots: any sidecar, CI runner, or monitoring agent that mounts the raw socket "for convenience" should be treated as a critical security decision, not a shortcut. The fix — which I'll detail in the last section — was routing everything through docker-socket-proxy with an explicit allowlist instead of raw access.
Mistake #2: We Didn't Gate Commands Behind Roles or Cooldowns
Even after I moved off legacy prefix commands to slash commands with app_commands, I made a second mistake: I assumed Discord's own server roles automatically limited who could run what. They don't. Unless you explicitly check interaction.user.roles or add a permission decorator, any member in the guild can run /restart — including the random person who joined last week to ask about server rules.
The incident that made this obvious: during a tournament, someone spammed /restart ark as a joke while mid-raid on the actual server. No role check, no cooldown, no confirmation step. The container restarted in the middle of combat and wiped a chunk of progress for a dozen players. Separately — and this one was worse — a misfiring webhook integration looped and fired /restart a dozen times in under a minute, which turned a routine restart into a genuine crash loop on a host that couldn't recover container state fast enough between attempts.
The lesson: Docker has no concept of "Alice from Discord." Discord's permission system doesn't map to bot authorization automatically. You have to build that layer yourself, explicitly, per command:
@app_commands.checks.has_role("server-admin") # role gate, not optional
@app_commands.checks.cooldown(1, 30.0) # 1 use per 30s per user
async def restart(self, interaction, container: str):
...
These two decorators are the actual authorization and rate-limiting layer for a discord bot docker restart command. Skipping either one turns a convenience feature into an incident generator.
Mistake #3: We Restarted Blind — No Health Check, No Async Handling
Once roles and cooldowns were in place, I thought we were done. Then the bot started lying to players. docker restart returns almost immediately — the API call succeeds the moment Docker issues the stop/start sequence, not when the game process is actually healthy again. Our bot replied "✅ restarted" the instant the API call returned, while the Minecraft container was actively crash-looping from a corrupted save file. Players saw a green checkmark and assumed everything was fine. We didn't find out for hours.
The corrupted saves themselves traced back to something unrelated but just as sloppy: Docker's default SIGTERM grace period is 10 seconds before a SIGKILL. Ten seconds isn't enough time for Valheim or Minecraft to finish writing world state to disk. Every "restart" was truncating the save routine.
The second bug was subtler. docker-py's calls are synchronous. Running them directly inside an async discord.py command blocks the bot's entire event loop — which means Discord's heartbeat stalls, and the bot shows as offline in every other guild for the duration of that blocking call. One restart in one guild froze the bot everywhere.
The fix for both: extend the grace period, add a delay plus a real status check before reporting success, and move blocking Docker calls off the event loop entirely with asyncio.to_thread. Also — watch out — polling docker inspect too aggressively for status updates will get you rate-limited by Discord itself if you push embed edits every few seconds; discord.errors.HTTPException: 429 shows up fast if you poll every 5s instead of every 30-60s.
What We Do Differently Now
The architecture we run today addresses all three mistakes directly, and it's not much more complex than the broken version — it's just deliberate. kuryzhev.cloud has more posts on this kind of infra hardening if you want the pattern applied elsewhere.
Instead of mounting the raw socket, the bot talks to tecnativa/docker-socket-proxy:0.2.0 over an internal Docker network. The proxy exposes an explicit allowlist — only container info, POST, and restart endpoints are enabled; images, volumes, networks, and exec are all denied by default, which is more restrictive than the proxy's out-of-the-box config:
version: "3.9"
services:
socket-proxy:
image: tecnativa/docker-socket-proxy:0.2.0
environment:
CONTAINERS: 1 # allow read on containers
POST: 1 # allow POST requests (needed for restart)
RESTART: 1 # explicitly allow /restart endpoint
INFO: 1 # allow /info for health checks
IMAGES: 0
VOLUMES: 0
NETWORKS: 0
EXEC: 0
BUILD: 0
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- bot-internal
restart: unless-stopped
discord-bot:
build: ./bot
environment:
DOCKER_HOST: tcp://socket-proxy:2375 # bot talks to proxy, never the raw socket
DISCORD_TOKEN: ${DISCORD_TOKEN}
ADMIN_ROLE_ID: ${ADMIN_ROLE_ID}
depends_on:
- socket-proxy
networks:
- bot-internal # to reach socket-proxy
- default # to reach discord.com's API
restart: unless-stopped
networks:
bot-internal:
internal: true # socket-proxy has zero outbound internet access
One gotcha we hit while building this: putting the bot only on the internal network blocks it from reaching Discord's own API, since internal: true networks have no outbound internet. The bot needs a second network for that — easy to miss until the bot never comes online.
On the command side, restarts are role-gated, cooldown-limited, run off the event loop, and don't report success until a real health check passes:
import asyncio
import discord
from discord import app_commands
from discord.ext import commands
import docker # docker-py 6.1.3, talks to DOCKER_HOST=tcp://socket-proxy:2375
client = docker.from_env()
class GameServerCog(commands.Cog):
@app_commands.command(name="restart", description="Restart a game server container")
@app_commands.checks.has_role("server-admin") # Mistake #2 fix
@app_commands.checks.cooldown(1, 30.0) # Mistake #2 fix
async def restart(self, interaction: discord.Interaction, container: str):
await interaction.response.defer()
try:
# Mistake #3 fix: blocking docker-py call off the event loop
c = await asyncio.to_thread(client.containers.get, container)
except docker.errors.NotFound:
await interaction.followup.send(f"No container named `{container}`.")
return
# Grace period bumped from default 10s to 30s so world saves finish
await asyncio.to_thread(c.restart, timeout=30)
await asyncio.sleep(15) # let the game process actually come back up
c.reload()
state = c.attrs["State"]
status = state.get("Health", {}).get("Status", state.get("Status"))
if status not in ("healthy", "running"):
await interaction.followup.send(
f"⚠️ `{container}` restarted but status is `{status}` — check logs, not confirmed healthy."
)
else:
await interaction.followup.send(f"✅ `{container}` restarted and healthy.")
Every restart, successful or not, also gets logged to a dedicated #server-audit channel via webhook with the actor's Discord ID and container name — separate from the interactive response, so there's a paper trail even if the ephemeral reply gets missed. That audit log has already settled two "who restarted this" arguments without me needing to check logs manually.
Resource-wise, the bot plus socket-proxy add maybe 20-30MB combined RAM on a small VPS — negligible. The only performance trap is polling: docker-py returns full container attributes on every call, so a tight polling loop against 10+ containers can spike CPU in ways that look like a Docker problem but are really a bot problem. Check the docker-py documentation before assuming any call is cheap just because it looks small in code.
None of this is exotic. It's the boring version of a discord bot docker restart feature — role checks, cooldowns, an allowlisted proxy, and a health check before claiming success. The interesting version is the one that pages you at 3am.
Top comments (1)
Mistake #3 is the one I'd tattoo somewhere visible. I build internal tools for a hospital (I'm a physical therapist by trade, not a developer), and the most expensive category of bug I've met is exactly that shape: the tool says "done" the moment it issued the command, not when the thing actually became true.
My version was a chat-bot that pings staff and some cron jobs on a NAS. Everything reported success for weeks. Then I discovered success meant "the HTTP request returned" — meanwhile one job had been silently writing garbage. Since then my rule is that a restart, a backup, a send — none of them are done until something independent confirms the new state. Your bot polling container health before replying is that rule with better engineering.
The role-gate lesson hits too. Internal tools tempt you to skip authorization because "it's just us." Then you watch a well-meaning colleague find the one button they shouldn't press, at the one moment it matters. Trusted users don't need less guardrail — they need guardrails that don't insult them.
Great writeup. The 10s vs 30s SIGTERM detail alone will save somebody's world save.