How to Deploy WhatsApp Bots With Docker

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

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:

  1. 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.
  2. Database Container (PostgreSQL): Persists incoming message logs, user conversation state, and workflow metrics.
  3. 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

  1. Auto-responder test: Send !ping from any external WhatsApp account to your bot’s phone number. The bot will respond instantly with pong from Docker container!.
  2. 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."}'
  1. 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:

  1. Provision a high-performance instance on Hetzner VPS or Contabo VPS .
  2. Point your DNS records using Namecheap to route incoming administrative hooks safely over HTTPS.
  3. 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
system online