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
- A working bot you can already run with one command, for example
python bot.pyornode index.js. - Your bot token from the Discord Developer Portal.
- A VPS or server with Docker installed. On most Linux boxes you can install it with the official convenience script.
- Basic comfort with a terminal.
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.



