How to Deploy WhatsApp Bots With Docker

What You’ll Need
- A cloud server running Ubuntu 22.04 LTS on a Hetzner VPS , Contabo VPS , or DigitalOcean droplet.
- A registered domain name from Namecheap configured with an A record pointing to your server’s IP address.
- Docker Engine (v24.0+) and Docker Compose (v2.20+) installed on your host system.
- Node.js 20+ installed locally for testing or development.
- A mobile device with an active WhatsApp account for initial authentication.
- Optional: n8n Cloud or a self-hosted instance to connect downstream webhooks.
Table of Contents
- Architecture Overview
- Building the WhatsApp Bot Application
- Containerizing the Application with Docker
- Securing and Scaling Nginx Postgres and Rate Limiting
- Deployment and QR Code Authentication
- Getting Started
Architecture Overview
Deploying WhatsApp bots in a self-hosted environment presents unique infrastructure challenges. Unlike standard HTTP API bots (like Telegram or Discord bots), WhatsApp Web sockets require persistent authentication state sessions, dynamic connection retry logic, and careful resource isolation. If your process restarts without mounting persistent auth stores, your bot gets disconnected, forcing you to re-scan a QR code.
In this architecture, I containerize a Node.js runtime utilizing @whiskeysockets/baileys (a lightweight WebSocket implementation of the WhatsApp Web protocol). The stack consists of three isolated container services running inside a dedicated Docker network:
- WhatsApp Container (Node.js): Manages the active WebSocket connection, renders terminal/web QR codes for initial authentication, stores session keys inside a persistent Docker volume, and exposes an internal HTTP API powered by Express.js.
- Database Container (PostgreSQL): Persists incoming message logs, user conversation state, and workflow metrics.
- Reverse Proxy Container (Nginx): Terminates TLS, exposes administrative webhooks, and protects the internal HTTP API against abuse.
This layout isolates application crashes from database corruption while ensuring session states survive host restarts or Docker updates.
Building the WhatsApp Bot Application
Let’s build the Node.js service from scratch. Create a new directory named whatsapp-docker-bot and create the project configuration files.
1. package.json
{
"name": "whatsapp-docker-bot",
"version": "1.0.0",
"description": "Production ready Dockerized WhatsApp Bot",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"@whiskeysockets/baileys": "^6.6.0",
"express": "^4.19.2",
"pg": "^8.11.5",
"qrcode-terminal": "^0.12.0"
}
}
2. init.sql
Create a file named init.sql to initialize our database schema when PostgreSQL first boots up:
CREATE TABLE IF NOT EXISTS message_logs (
id SERIAL PRIMARY KEY,
sender_jid VARCHAR(255) NOT NULL,
message_body TEXT NOT NULL,
timestamp TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS bot_state (
key VARCHAR(255) PRIMARY KEY,
value TEXT NOT NULL
);
3. index.js
This script handles connection management, state synchronization, automatic reconnection, and PostgreSQL message logging.
const { default: makeWASocket, useMultiFileAuthState, DisconnectReason } = require('@whiskeysockets/baileys');
const express = require('express');
const qrcode = require('qrcode-terminal');
const { Pool } = require('pg');
const path = require('path');
const fs = require('fs');
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 3000;
const AUTH_DIR = process.env.AUTH_DIR || './auth_info_baileys';
const pool = new Pool({
host: process.env.DB_HOST || 'postgres',
port: parseInt(process.env.DB_PORT || '5432', 10),
user: process.env.DB_USER || 'botuser',
password: process.env.DB_PASSWORD || 'botpassword',
database: process.env.DB_NAME || 'whatsapp_bot_db',
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
let sock;
async function logMessageToDatabase(senderJid, messageBody) {
const query = 'INSERT INTO message_logs (sender_jid, message_body) VALUES ($1, $2)';
try {
await pool.query(query, [senderJid, messageBody]);
} catch (error) {
console.error('Failed to log message to database:', error);
}
}
async function connectToWhatsApp() {
if (!fs.existsSync(AUTH_DIR)) {
fs.mkdirSync(AUTH_DIR, { recursive: true });
}
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
sock = makeWASocket({
auth: state,
printQRInTerminal: false,
defaultQueryTimeoutMs: undefined,
});
sock.ev.on('creds.update', saveCreds);
sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
console.log('Scan the QR code below to pair your WhatsApp bot:');
qrcode.generate(qr, { small: true });
}
if (connection === 'close') {
const statusCode = lastDisconnect?.error?.output?.statusCode;
const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
console.log(`Connection closed due to ${lastDisconnect?.error}. Reconnecting: ${shouldReconnect}`);
if (shouldReconnect) {
setTimeout(connectToWhatsApp, 5000);
} else {
console.log('Logged out from WhatsApp. Clear auth storage and restart container.');
}
} else if (connection === 'open') {
console.log('WhatsApp connection established successfully!');
}
});
sock.ev.on('messages.upsert', async (m) => {
if (m.type === 'notify') {
for (const msg of m.messages) {
if (!msg.key.fromMe) {
const senderJid = msg.key.remoteJid;
const textMessage = msg.message?.conversation || msg.message?.extendedTextMessage?.text;
if (textMessage) {
console.log(`Received message from ${senderJid}: ${textMessage}`);
await logMessageToDatabase(senderJid, textMessage);
if (textMessage.toLowerCase() === '!ping') {
await sock.sendMessage(senderJid, { text: 'pong from Docker container!' });
}
}
}
}
}
});
}
app.get('/health', async (req, res) => {
try {
const dbRes = await pool.query('SELECT NOW()');
res.status(200).json({ status: 'ok', db_time: dbRes.rows[0].now });
} catch (error) {
res.status(500).json({ status: 'error', message: error.message });
}
});
app.post('/send-message', async (req, res) => {
const { recipient, message } = req.body;
if (!recipient || !message) {
return res.status(400).json({ error: 'recipient and message fields are required' });
}
try {
const formattedJid = recipient.includes('@s.whatsapp.net') ? recipient : `${recipient}@s.whatsapp.net`;
await sock.sendMessage(formattedJid, { text: message });
return res.status(200).json({ success: true, sent_to: formattedJid });
} catch (error) {
console.error('Failed to send outbound API message:', error);
return res.status(500).json({ error: 'Failed to send WhatsApp message' });
}
});
app.listen(PORT, () => {
console.log(`HTTP management API operating on port ${PORT}`);
connectToWhatsApp();
});
๐ก Fast-Track Your Project: Don’t want to configure this yourself? I build custom n8n pipelines and bots. Message me with code SYS3-HUGO.
Containerizing the Application with Docker
To deploy this application cleanly on a host like a Hetzner VPS or DigitalOcean server, we must write a lean production Dockerfile and stack it with Compose. When setting up multi-container deployments, mastering multi-stage builds and persistent volume binding is essential. Check out my full guide on Deploying Docker Containers for Efficient Workflow Management for deeper orchestration strategies.
1. Dockerfile
Create a multi-stage Dockerfile to produce a minimal final image without build tools.
FROM node:20-alpine AS builder
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm ci --only=production
FROM node:20-alpine
NODE_ENV=production
WORKDIR /usr/src/app
COPY package*.json ./
COPY --from=builder /usr/src/app/node_modules ./node_modules
COPY index.js ./
EXPOSE 3000
CMD ["node", "index.js"]
2. docker-compose.yml
This manifest configures network dependencies, persistent storage volumes for both our session auth state and PostgreSQL records, and auto-restart policies.
version: '3.8'
services:
whatsapp-bot:
build:
context: .
dockerfile: Dockerfile
container_name: whatsapp_bot_app
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
environment:
PORT: 3000
AUTH_DIR: /usr/src/app/auth_info_baileys
DB_HOST: postgres
DB_PORT: 5432
DB_USER: botuser
DB_PASSWORD: botpassword
DB_NAME: whatsapp_bot_db
volumes:
- whatsapp_auth_data:/usr/src/app/auth_info_baileys
depends_on:
postgres:
condition: service_healthy
networks:
- bot_network
postgres:
image: postgres:15-alpine
container_name: whatsapp_bot_postgres
restart: unless-stopped
environment:
POSTGRES_USER: botuser
POSTGRES_PASSWORD: botpassword
POSTGRES_DB: whatsapp_bot_db
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U botuser -d whatsapp_bot_db"]
interval: 10s
timeout: 5s
retries: 5
networks:
- bot_network
nginx:
image: nginx:alpine
container_name: whatsapp_bot_proxy
restart: unless-stopped
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- whatsapp-bot
networks:
- bot_network
volumes:
whatsapp_auth_data:
postgres_data:
networks:
bot_network:
driver: bridge
Securing and Scaling Nginx Postgres and Rate Limiting
Exposing your bot’s internal REST API directly to the web is dangerous. Malicious actors could hit your /send-message endpoint to trigger spam blasts, causing your WhatsApp phone number to get permanently banned.
We must run Nginx in front of our application to handle rate limiting and drop unauthorized requests. I cover comprehensive strategies on this subject in my guide to Protecting Self Hosted Endpoints with Rate Limiting .
Create an nginx.conf file:
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
limit_req_zone $binary_remote_addr zone=bot_limit:10m rate=5r/s;
upstream bot_backend {
server whatsapp-bot:3000;
}
server {
listen 80;
server_name localhost;
location /health {
proxy_pass http://bot_backend/health;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /send-message {
limit_req zone=bot_limit burst=10 nodelay;
proxy_pass http://bot_backend/send-message;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}
Additionally, if your WhatsApp bot experiences explosive traffic growth, database query latency can back up your event loop. Optimizing connection limits ensures your API handles heavy request bursts without dropping socket connections. For deep tuning recipes, review my architectural walk-through on Configuring PostgreSQL Connection Pooling on Linux Servers .
Deployment and QR Code Authentication
Now that the complete stack is assembled, launch the containers in detached mode:
docker compose up -d --build
Monitor the container creation and database initialization process:
docker compose ps
To pair your bot with WhatsApp, view the realtime logs of the whatsapp_bot_app container where Baileys renders the ASCII QR code:
docker logs -f whatsapp_bot_app
Output will display inside your server terminal:
HTTP management API operating on port 3000
Scan the QR code below to pair your WhatsApp bot:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโ โโโโโ โโ โ โ โโโ โโโโโ โโโโ
โโโโ โ โ โ โ โ โ โ โ โ โโโโ
โโโโ โโโโโ โ โโ โ โ โโโโโ โโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
...
Open WhatsApp on your phone, navigate to Settings > Linked Devices > Link a Device, and point your camera at the terminal screen.
Once scanned, the terminal output shifts:
WhatsApp connection established successfully!
Testing the Bot Interactively
- Auto-responder test: Send
!pingfrom any external WhatsApp account to your bot’s phone number. The bot will respond instantly withpong from Docker container!. - Outbound API Test: Issue a curl request against your Nginx proxy to trigger an automated notification:
curl -X POST http://localhost/send-message \
-H "Content-Type: application/json" \
-d '{"recipient": "15551234567", "message": "Automated build alert from backend process."}'
- Verify PostgreSQL Persistence: Query your database container directly to verify message logging:
docker exec -it whatsapp_bot_postgres psql -U botuser -d whatsapp_bot_db -c "SELECT * FROM message_logs;"
Getting Started
To spin up this exact stack in production:
- Provision a high-performance instance on Hetzner VPS or Contabo VPS .
- Point your DNS records using Namecheap to route incoming administrative hooks safely over HTTPS.
- Deploy the Docker stack, complete the initial terminal QR code scan, and begin building your message workflows.
Outsource Your Automation
Don’t have time? I build production n8n workflows, WhatsApp bots, and fully automated YouTube Shorts pipelines. Hire me on Fiverr โ mention SYS3-HUGO for priority. Or DM at chasebot.online .
Want to automate this yourself?
Start with n8n Cloud (free tier available) or self-host on a Hetzner VPS for full control.
Want this engine running on your own VPS?
This blog publishes itself โ daily, unattended, on free API tiers. The full engine, Hugo theme, and setup guide are available as System 3.
Get System 3