Deploying Self Hosted Telegram Bots with Docker

Deploying Self Hosted Telegram Bots with Docker

What You’ll Need

Before we dive into building and deploying our containerized bot, here is what you need in your stack:

  • A cloud server like a Hetzner VPS or Contabo VPS running Ubuntu 22.04 LTS (a DigitalOcean droplet also works well).
  • A custom domain managed through Namecheap to point traffic to your server for secure webhook delivery.
  • Docker Engine and Docker Compose installed on your deployment server.
  • Node.js (version 20 LTS or higher) installed on your local machine for initial testing.
  • A Telegram account and a Telegram Bot token obtained from BotFather.
  • Optional automation integrations such as n8n Cloud or Make.com if you plan to extend your bot with low-code workflows.

Table of Contents


Architecture: Long Polling vs Webhooks for Telegram Bots

When I build Telegram bots for client infrastructure or internal operations, I always start by picking the right connection model: Long Polling or Webhooks.

Long Polling opens an outbound HTTP connection from your application server to Telegram’s servers. Your application asks Telegram for new updates repeatedly in a loop. This works well for rapid local prototyping because you do not need a public IP address, SSL certificate, or domain name. However, long polling struggles when scaling under high concurrency. It makes resource cleanup difficult during server reboots, and it forces your server to keep persistent socket connections open continuously.

Webhooks, on the other hand, reverse this connection model. Telegram pushes incoming user messages directly to your public HTTPS endpoint as an HTTP POST request the instant a user sends a message. This event-driven model conserves server CPU cycles and RAM because your process only executes when an update arrives. Furthermore, running webhooks behind a web server allows you to handle incoming traffic using standard web patterns like rate limiting, load balancing, and firewall rules.

When deciding between simple event hooks and orchestrating complex asynchronous background tasks, reading our comparison on Temporal vs Airflow for API-First Automation can help you decide how far to isolate state execution from your interface bot. For production deployment, webhooks are the superior operational choice.


Building the Production-Ready Telegram Bot

Let us build a Node.js bot using the Telegraf framework and Express. We will run our application on a Hetzner VPS server using webhooks, so we must build security directly into our webhook handler. Telegram provides a custom header header field called X-Telegram-Bot-Api-Secret-Token when setting up webhooks. We validate this header on every inbound payload to verify that incoming traffic originates from Telegram and not an unauthorized actor.

In addition to origin verification, Telegram can occasionally re-send updates if network glitches interrupt the HTTP acknowledgement response. To prevent duplicate executions of destructive commands, we track update IDs in an in-memory set cache. If you want to learn more about preventing duplicate executions at scale across distributed setups, read our guide on How To Implement Idempotent Webhook Payload Processing.

Below is the complete package.json file required for this application:

{
  "name": "telegram-docker-bot",
  "version": "1.0.0",
  "description": "Production-grade self-hosted Telegram bot",
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "express": "4.19.2",
    "telegraf": "4.16.3"
  }
}

Now, here is the full application code in index.js. It includes explicit token verification, payload deduplication, error boundaries, and graceful termination handling:

const { Telegraf } = require('telegraf');
const express = require('express');

const BOT_TOKEN = process.env.BOT_TOKEN;
const WEBHOOK_DOMAIN = process.env.WEBHOOK_DOMAIN;
const SECRET_TOKEN = process.env.SECRET_TOKEN;
const PORT = parseInt(process.env.PORT || '3000', 10);

if (!BOT_TOKEN) {
  throw new Error('BOT_TOKEN environment variable is required');
}
if (!WEBHOOK_DOMAIN) {
  throw new Error('WEBHOOK_DOMAIN environment variable is required');
}
if (!SECRET_TOKEN) {
  throw new Error('SECRET_TOKEN environment variable is required');
}

const bot = new Telegraf(BOT_TOKEN);
const app = express();

const processedUpdateIds = new Set();
const MAX_CACHE_SIZE = 10000;

function isDuplicateUpdate(updateId) {
  if (processedUpdateIds.has(updateId)) {
    return true;
  }
  processedUpdateIds.add(updateId);
  if (processedUpdateIds.size > MAX_CACHE_SIZE) {
    const oldestValue = processedUpdateIds.values().next().value;
    processedUpdateIds.delete(oldestValue);
  }
  return false;
}

app.use(express.json());

bot.start((ctx) => {
  ctx.reply('Hello! This bot is running inside a self-hosted Docker container behind Nginx.');
});

bot.command('status', (ctx) => {
  const uptimeSeconds = Math.floor(process.uptime());
  const ramUsageMb = (process.memoryUsage().rss / 1024 / 1024).toFixed(2);
  ctx.reply(`Server Status:\nUptime: ${uptimeSeconds}s\nRAM Usage: ${ramUsageMb} MB`);
});

bot.on('text', (ctx) => {
  const userText = ctx.message.text;
  ctx.reply(`Echo: ${userText}`);
});

bot.catch((err, ctx) => {
  console.error(`Unhandled error for update ${ctx.update.update_id}:`, err);
});

app.get('/healthz', (req, res) => {
  res.status(200).json({ status: 'healthy', timestamp: new Date().toISOString() });
});

app.post('/webhook', (req, res) => {
  const incomingSecret = req.headers['x-telegram-bot-api-secret-token'];
  if (incomingSecret !== SECRET_TOKEN) {
    console.warn('Unauthorized webhook attempt detected. Invalid secret token.');
    return res.status(403).json({ error: 'Forbidden' });
  }

  const update = req.body;
  if (!update || !update.update_id) {
    return res.status(400).json({ error: 'Invalid update payload' });
  }

  if (isDuplicateUpdate(update.update_id)) {
    console.log(`Skipping duplicate update ID: ${update.update_id}`);
    return res.status(200).json({ status: 'ignored_duplicate' });
  }

  bot.handleUpdate(update, res).catch((err) => {
    console.error('Error handling Telegram update:', err);
    if (!res.headersSent) {
      res.status(500).json({ error: 'Internal server error' });
    }
  });
});

app.listen(PORT, async () => {
  console.log(`Express server listening on port ${PORT}`);
  const webhookUrl = `${WEBHOOK_DOMAIN}/webhook`;
  try {
    await bot.telegram.setWebhook(webhookUrl, {
      secret_token: SECRET_TOKEN,
      drop_pending_updates: true,
    });
    console.log(`Successfully registered webhook URL: ${webhookUrl}`);
  } catch (error) {
    console.error('Failed to set Telegram webhook:', error);
  }
});

function gracefulShutdown(signal) {
  console.log(`Received ${signal}. Starting graceful shutdown...`);
  bot.telegram.deleteWebhook().then(() => {
    console.log('Webhook removed successfully.');
    process.exit(0);
  }).catch((err) => {
    console.error('Error removing webhook:', err);
    process.exit(1);
  });
}

process.once('SIGINT', () => gracefulShutdown('SIGINT'));
process.once('SIGTERM', () => gracefulShutdown('SIGTERM'));

If you are expanding this pattern to integrate external webhooks into your application infrastructure, review our guide on Securing Incoming Webhooks with HMAC Signature Verification to protect every entry point across your service mesh.

💡 Fast-Track Your Project: Don’t want to configure this yourself? I build custom n8n pipelines and bots. Message me with code SYS3-HUGO.


Dockerizing the Bot with Multi-Stage Builds

To deploy this Node.js bot safely on our server, we must build a minimal Docker container. We will use a multi-stage Dockerfile based on node:20-alpine to keep image size small and eliminate build dependencies from the runtime image.

Create a file named Dockerfile in your project root:

FROM node:20-alpine AS builder
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm ci --only=production

FROM node:20-alpine AS runner
WORKDIR /usr/src/app
ENV NODE_ENV=production
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --chown=appuser:appgroup package*.json ./
COPY --chown=appuser:appgroup --from=builder /usr/src/app/node_modules ./node_modules
COPY --chown=appuser:appgroup index.js ./
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/healthz || exit 1
CMD ["node", "index.js"]

Next, define the runtime architecture using Docker Compose. Create a file named docker-compose.yml in the same directory:

version: '3.8'

services:
  telegram-bot:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: telegram_bot_app
    restart: always
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      - NODE_ENV=production
      - PORT=3000
      - BOT_TOKEN=${BOT_TOKEN}
      - WEBHOOK_DOMAIN=${WEBHOOK_DOMAIN}
      - SECRET_TOKEN=${SECRET_TOKEN}
    networks:
      - bot-network

networks:
  bot-network:
    driver: bridge

Create a .env file alongside your Docker Compose configuration to store environment variables safely:

BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyZ
WEBHOOK_DOMAIN=https://bot.yourdomain.com
SECRET_TOKEN=a_very_long_random_string_for_security_12345

Configuring Nginx Reverse Proxy and SSL Certificates

Telegram requires all webhook endpoints to use valid HTTPS SSL certificates signed by a trusted certificate authority. Self-signed certificates are rejected by default unless configured manually in Telegram’s payload options.

We will use Nginx on our host machine to terminate TLS requests, pass traffic down to our Docker container running on port 3000, and handle domain redirection. You can register your custom domain easily using Namecheap and point its DNS A-record to your server’s IP address.

Here is the complete Nginx configuration file. Save this to /etc/nginx/sites-available/bot.yourdomain.com:

server {
    listen 80;
    listen [::]:80;
    server_name bot.yourdomain.com;

    location /.well-known/acme-challenge/ {
        root /var/www/html;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name bot.yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/bot.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/bot.yourdomain.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers on;

    access_log /var/log/nginx/telegram_bot_access.log;
    error_log /var/log/nginx/telegram_bot_error.log;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
        
        proxy_connect_timeout 10s;
        proxy_read_timeout 30s;
        proxy_send_timeout 30s;
    }
}

Deploying and Managing Production Containers

Now we are ready to deploy our code to our live server environment.

Step 1: Provision SSL Certificates

First, install Certbot and obtain an SSL certificate for your domain:

sudo apt update
sudo apt install -y certbot python3-certbot-nginx nginx
sudo certbot certonly --webroot -w /var/www/html -d bot.yourdomain.com

Step 2: Enable the Nginx Site

Link the Nginx configuration file and reload the web server daemon:

sudo ln -s /etc/nginx/sites-available/bot.yourdomain.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Step 3: Launch Docker Compose

Clone your code repository onto the server, navigate to the folder containing your docker-compose.yml and .env files, and launch your application in detached mode:

docker compose up -d --build

Step 4: Monitor Container Logs and Health

Verify that your container initialized properly and registered the Telegram webhook:

docker compose logs -f telegram-bot

To test container resilience, trigger a healthcheck command manually:

docker inspect --format='{{json .State.Health}}' telegram_bot_app

Your self-hosted Telegram bot is now completely configured, running inside an isolated Docker container behind an encrypted Nginx proxy.


Getting Started

To spin up your production environment quickly, set up your infrastructure with these providers:

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