Get your free server today! View Plans →
Home Plans Blog About Contact Panel Join Discord
Discord Bots

How to dockerize and deploy a Discord bot

Package your Discord bot into a Docker image and run it on a VPS with auto restart, a database, and the token kept out of your code. Works for Python and Node bots.

How to dockerize and deploy a Discord bot

By the end of this guide you'll have your Discord bot packed into a Docker image, running on a VPS, restarting itself if it crashes, and keeping its token out of the source code. It works the same way whether your bot is written in Python or Node.js. This is aimed at people who already have a working bot script and now want to run it somewhere reliable instead of a laptop that goes to sleep.

Why bother with Docker for a bot?

A Discord bot is a long running process. It needs to stay online, reconnect after network blips, and start back up after a reboot. The annoying part is usually the environment: the right Python or Node version, the exact libraries, and a dozen small system packages. When you move the bot to a new machine, all of that has to match again.

Docker fixes this by bundling your code and its environment into one image. That image runs the same on your machine, on a VPS, and on a friend's server. There's no "but it worked locally" moment. You also get clean restarts, isolated dependencies, and an easy path to add a database next to the bot later. So even for a small project, it pays off quickly.

What you need first

If Docker isn't installed yet, this gets you the engine and the compose plugin on a fresh Ubuntu or Debian box:

curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
# log out and back in so the group change takes effect
docker --version
docker compose version

You should see version numbers print for both commands. If docker compose version errors out, your install is too old and you're stuck with the separate docker-compose binary. The newer plugin syntax is what we'll use here.

Step 1: Lock down your dependencies

Before writing any Docker files, make sure your dependencies are pinned. Docker can only rebuild what you tell it to install.

For a Python bot, you want a requirements.txt. If you don't have one, generate it from your virtual environment:

pip freeze > requirements.txt

A typical file for a discord.py bot looks like this:

discord.py==2.4.0
python-dotenv==1.0.1
aiohttp==3.9.5

For a Node bot, you already have a package.json and a package-lock.json. Make sure the lock file is committed. It's what makes builds repeatable. A minimal package.json for a discord.js bot might read:

{
  "name": "my-discord-bot",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "discord.js": "^14.15.3",
    "dotenv": "^16.4.5"
  }
}

Step 2: Read the token from the environment

This is the one rule you should never break. Do not write your token in the code, and do not bake it into the image. Anyone who pulls the image or sees your repo could grab it. Read it from an environment variable instead.

In Python, with python-dotenv:

import os
import discord
from dotenv import load_dotenv

load_dotenv()  # reads a local.env when running outside Docker

TOKEN = os.environ["DISCORD_TOKEN"]

intents = discord.Intents.default()
intents.message_content = True
client = discord.Client(intents=intents)

@client.event
async def on_ready():
    print(f"Logged in as {client.user}")

client.run(TOKEN)

In Node, with dotenv:

require('dotenv').config();
const { Client, GatewayIntentBits } = require('discord.js');

const client = new Client({
  intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
});

client.once('ready', () => {
  console.log(`Logged in as ${client.user.tag}`);
});

client.login(process.env.DISCORD_TOKEN);

Using os.environ["DISCORD_TOKEN"] rather than .get() is on purpose. If the variable is missing, the bot fails loudly at startup instead of silently trying to log in with nothing. That makes a misconfigured deploy obvious right away.

Step 3: Write the Dockerfile

The Dockerfile is the recipe for your image. Pick the version that matches your bot.

Python Dockerfile

FROM python:3.12-slim

# Don't write.pyc files, and flush logs straight to the console
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

# Copy requirements first so this layer is cached between code changes
COPY requirements.txt.
RUN pip install --no-cache-dir -r requirements.txt

# Now copy the rest of the bot
COPY..

CMD ["python", "bot.py"]

Node Dockerfile

FROM node:20-slim

WORKDIR /app

# Copy manifest and lock file first for layer caching
COPY package*.json./
RUN npm ci --omit=dev

COPY..

CMD ["node", "index.js"]

Two things are worth explaining here. First, the slim base images are much smaller than the full ones, which means faster pulls and a smaller attack surface. Second, copying the dependency file before the rest of the code is a caching trick. Docker builds in layers, and it reuses a layer if nothing it depends on changed. So when you tweak your bot code but not your dependencies, the install step is skipped and rebuilds take seconds instead of minutes.

The PYTHONUNBUFFERED=1 line matters more than it looks. Without it, Python buffers its output and your docker logs can sit empty even though the bot is running. With it, every print shows up immediately.

Step 4: Add a.dockerignore

Without a .dockerignore, the build copies your whole folder into the image. That includes your virtual environment, your local .env with the real token, your git history, and any cache junk. You don't want any of that shipped. Create a .dockerignore next to the Dockerfile:

.git.gitignore.env
*.env
__pycache__/
*.pyc
venv/.venv/
node_modules/
npm-debug.log
Dockerfile
docker-compose.yml
README.md

Ignoring node_modules is important for Node bots. You want Docker to install fresh inside the image, not copy in packages built for your host operating system. And ignoring .env keeps your token out of the image even if you slip up elsewhere.

Step 5: Build and run it once by hand

Before bringing in compose, prove the image works. Build it:

docker build -t my-discord-bot.

The -t flag names the image. The . at the end tells Docker to use the current folder as the build context. When it finishes you'll see something like Successfully tagged my-discord-bot:latest.

Now run it, passing the token as an environment variable on the command line:

docker run --rm -e DISCORD_TOKEN="your-token-here" my-discord-bot

If everything is wired up, you'll see your "Logged in as" message in the terminal. The --rm flag removes the container when it stops, which keeps things tidy while you're testing. Press Ctrl+C to stop it. If it logged in cleanly, you're ready to make this permanent.

Step 6: Use Docker Compose for the bot and a database

Running a long command by hand every time gets old, and most real bots eventually need a database. Compose handles both. It reads a single YAML file that describes your services and how they connect.

First, keep your secrets in a .env file in the project folder. Compose reads it automatically:

DISCORD_TOKEN=your-real-token-goes-here
POSTGRES_PASSWORD=a-long-random-password
POSTGRES_USER=botuser
POSTGRES_DB=botdata

Make sure that file is in your .gitignore too. Now the docker-compose.yml for a bot plus a PostgreSQL database:

services:
  bot:
    build:.
    restart: unless-stopped
    env_file:.env
    depends_on:
      - db

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - dbdata:/var/lib/postgresql/data

volumes:
  dbdata:

A few details to notice. The env_file:.env line hands your token to the bot service without it ever touching the image. The depends_on tells Docker to start the database before the bot. The named volume dbdata is where the database actually stores its files, and because it lives outside the container, your data survives rebuilds and restarts. Inside your bot, you'd connect to the database using the hostname db, which is the service name, on port 5432.

Start the whole stack in the background:

docker compose up -d --build

The -d runs it detached so it keeps going after you close the terminal. The --build forces a rebuild of your bot image from the latest code. Check that both containers are up:

docker compose ps

You should see both bot and db with a state of running or up.

Step 7: Auto restart and reading logs

The restart: unless-stopped policy is doing real work. If your bot crashes because of a temporary error or the server reboots, Docker brings it back automatically. It only stays down if you explicitly stopped it yourself. This is the simplest way to keep a bot online around the clock without a separate process manager.

To watch what your bot is doing, follow its logs:

docker compose logs -f bot

The -f means follow, so new lines stream in live. Press Ctrl+C to stop watching (that doesn't stop the bot, just the log view). To see only the last chunk without following:

docker compose logs --tail 50 bot

This is the first place to look whenever something seems off. If the bot is crash looping, the error will be right there.

Step 8: Updating the bot later

When you change your code, you need to rebuild the image and swap the running container. With compose that's two short commands, or one if you combine them:

# pull your latest code first, for example
git pull

# rebuild and recreate only what changed
docker compose up -d --build

Compose is smart enough to leave the database running and only recreate the bot if its image changed. Your database volume is untouched, so no data is lost. If you want to free up disk from old images now and then:

docker image prune -f

That removes dangling images that nothing points to anymore. It's safe to run regularly.

This setup runs comfortably on a small VPS. On our own hosting at Bytte.cloud, a basic plan with a gigabyte of RAM is plenty for a bot and a small Postgres database side by side, since neither uses much when idle.

Troubleshooting

Here are the problems that come up most often and how to fix them fast.

The container exits immediately

You start it, and it's gone a second later. Run it without -d so you can see the output, or check the logs. The most common cause is a missing token, which leads straight into the next issue. The second most common cause is a crash on startup from a bug in your code or a missing dependency. The log will tell you which.

KeyError: 'DISCORD_TOKEN' or "token not provided"

The bot can't find its token. Check that your .env file exists in the same folder as docker-compose.yml, that the variable is spelled exactly DISCORD_TOKEN, and that there are no quotes or spaces around the value. You can confirm what the container actually sees with:

docker compose run --rm bot env | grep DISCORD

If nothing prints, the variable isn't reaching the container and your env_file path is wrong.

Code changes don't show up

You edited the bot, restarted, and it's running the old version. You forgot the --build flag, so Docker reused the old image. Always run docker compose up -d --build after a code change. If it still seems stale, force a clean build with docker compose build --no-cache bot, which ignores the layer cache entirely.

The bot can't reach the database

Connection refused errors usually mean one of two things. Either you used localhost as the database host instead of the service name db, or the bot started before the database finished booting. depends_on waits for the container to start, not for Postgres to be ready to accept connections. The clean fix is to retry the connection in your bot with a short backoff so it can wait out those first few seconds.

Permission denied when running docker

If every docker command needs sudo, add yourself to the docker group with the usermod command from the setup section, then log out and back in. The group change only applies to a fresh login session.

Logs are empty even though the bot works

For Python bots this is almost always output buffering. Confirm PYTHONUNBUFFERED=1 is in your Dockerfile. For any bot, remember that print and console.log go to standard output, which is exactly what docker logs captures, so use those rather than writing to a file inside the container.

Wrapping up

You've now got a bot that builds into a portable image, runs alongside a database with one command, restarts on its own, and keeps its token in a place you control. The same docker-compose.yml moves to any host that runs Docker, which makes switching servers a copy and a single up command. From here, good next steps are adding healthchecks so Docker can detect a wedged bot, setting a memory limit so a runaway process can't take down the whole box, and scripting a regular dump of your database volume. But the core is done, and your bot will stay online whether you're watching it or not.

Common questions

How do I pass my Discord bot token to a Docker container safely?

Read the token from an environment variable in your code, then supply it at runtime. Put it in a.env file that Docker Compose loads with env_file, and add that file to.gitignore and.dockerignore. Never write the token in your code or bake it into the image.

Why does my Discord bot container exit right after starting?

Run it without the -d flag or check docker compose logs to see the error. The usual causes are a missing token (a KeyError on startup) or a crash from a code bug or missing dependency. The log message will point to which one it is.

Should I use a Dockerfile or Docker Compose for a Discord bot?

Use a Dockerfile to define the image, and Docker Compose to run it. Compose makes restarts, environment variables, and adding a database alongside the bot far easier, and it replaces a long docker run command with a single docker compose up.

How do I update my bot after changing the code?

Pull your latest code, then run docker compose up -d --build. The --build flag rebuilds the bot image, Compose recreates only the bot container, and your database volume stays untouched so no data is lost.

How do I keep a Discord bot running after a crash or reboot?

Set restart: unless-stopped on the bot service in your docker-compose.yml. Docker will restart the bot if it crashes or the server reboots, and it stays down only when you stop it yourself.

DO
Daniel Okafor
Web Developer at Bytte.cloud

Part of the Bytte.cloud team. We run game servers, bots and websites for a living, and we write these guides from what we see day to day in support and on our own servers.

Want to try this on real hardware?

Bytte.cloud has free plans for game servers, bots and websites. No credit card, set up in seconds.

Start for free See the plans