Discord Bot Docker Restart: 3 Mistakes That Bit Us in Prod

devops-games-rust

Context: Why We Built a Discord Bot for Our Game Server Fleet

discord bot docker restart illustration

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.

Related

Leave a Reply

Your email address will not be published. Required fields are marked *

โ˜• Support us ยท ๐Ÿ’ณ Monobank