Why containerize a bot
- Reproducible builds. The same Python version and pinned dependencies everywhere.
- Portable hosting. The image runs on a VPS, on platforms that build from a Dockerfile (Railway, Fly.io, Render) or on a home server.
- Restarts for free. Docker's restart policy brings the bot back after a crash or a reboot.
If you have not decided where to run it yet, see PaaS vs VPS for bots and our overview deploying a Python bot.
Project layout
mybot/
bot/
__init__.py
main.py
requirements.txt
Dockerfile
.dockerignore
compose.yaml
.env # never committed
Pin dependencies in requirements.txt (for example python-telegram-bot==22.* or discord.py==2.*) so a rebuild next month installs the same major version.
The Dockerfile
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
# Dependencies first, so code changes do not reinstall them
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY bot/ bot/
# Run as an unprivileged user
RUN useradd --create-home --uid 10001 app
USER app
# Exec form: Python is PID 1 and receives SIGTERM directly
CMD ["python", "-m", "bot.main"]
Details that matter:
PYTHONUNBUFFERED=1makesprintand logging appear immediately indocker logs. Without it, output can sit in a buffer and you see nothing when the bot crashes.- Copying
requirements.txtbefore the code keeps the dependency layer cached. - Exec form (
["python", ...]) instead of shell form (python -m bot.main). In shell form/bin/shbecomes PID 1 and may not forward signals, sodocker stopwaits for its grace period and then kills the bot. See graceful shutdown for bots. - The
-slimimage is a good default. Alpine images use musl, which can make some Python wheels slower to install or require compilers.
.dockerignore
.env
.git
__pycache__/
*.pyc
.venv/
tests/
Excluding .env matters: without it, COPY . . would bake your token into an image layer, and anyone with the image could extract it.
compose.yaml
services:
bot:
build: .
restart: unless-stopped
env_file: .env
stop_grace_period: 20s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
restart: unless-stoppedrestarts the bot after crashes and after the Docker daemon starts on boot, unless you stopped it manually.env_fileinjectsTELEGRAM_TOKEN,DISCORD_TOKENand friends at runtime. Validate the file with our .env validator.- The default
json-filelog driver keeps growing unless you cap it;max-sizeandmax-filestop a chatty bot from filling the disk.
Start it and follow the logs:
docker compose up -d --build
docker compose logs -f bot
Webhook bots: expose a port
A bot that receives webhooks (WhatsApp Cloud API, Telegram in webhook mode) must listen on a port and sit behind HTTPS:
ports:
- "127.0.0.1:8080:8080"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3)"]
interval: 30s
timeout: 5s
retries: 3
Binding to 127.0.0.1 keeps the port private; put a reverse proxy such as Caddy or nginx in front for TLS. On a PaaS, read the port from the PORT environment variable instead. Polling and gateway bots (Discord) need no open port at all.
Running it in production
- VPS: install Docker Engine, copy the project (or pull the image from a registry), create
.envwithchmod 600, thendocker compose up -d. Our Vultr VPS tutorial covers the server hardening part. - PaaS: platforms like Railway detect the Dockerfile and build it; set variables in the dashboard instead of an
.envfile. See deploy a bot on Railway. - Updates:
git pull && docker compose up -d --buildrebuilds and replaces the container. Run only one replica of a polling or gateway bot, or it will receive every event twice (Telegram returns a 409 Conflict to the second poller).
Frequently asked questions
Do I need Docker to deploy a Python bot?
No. A virtualenv plus a systemd unit works well on a VPS. Docker helps when you want the same setup on several hosts or a platform that builds images.
Why is my container restarting in a loop?
The bot exits on start, usually because a required environment variable is missing or the token is invalid. Read docker compose logs bot; with PYTHONUNBUFFERED=1 the traceback is there.
How do I keep data across rebuilds?
Use a named volume for files such as a SQLite database (volumes: ["botdata:/app/data"]), or a managed database. Anything written inside the container is lost when it is recreated.
Sources
- Docker docs, Dockerfile reference (CMD, exec form): docs.docker.com/reference/dockerfile
- Docker docs, restart policies: docs.docker.com/engine/containers/start-containers-automatically
- Docker docs, JSON file logging driver: docs.docker.com/engine/logging/drivers/json-file
- Docker docs, Compose file reference: docs.docker.com/reference/compose-file/services
- Python docs, PYTHONUNBUFFERED: docs.python.org/3/using/cmdline.html