PAS7 Studio
Back to all articles

Deploy a Telegram Bot Webhook with Node.js, TypeScript, grammY, Nginx, SSL, and systemd

A practical 2026-ready tutorial for deploying a Telegram bot webhook on a VPS with Node.js, TypeScript, grammY, Nginx, Let's Encrypt SSL, and systemd. Includes working code, webhook registration, and production checks.

08 Apr 2026· 8 min read· How-To
Best forBackend engineersNode.js developersDevelopers deploying their first Telegram bot on a VPSTeams moving a bot from local testing to productionFounders who want a simple bot deployment without unnecessary layers
Telegram bot webhook deployed with Node.js, TypeScript, grammY, Nginx, SSL, and systemd on a VPS

This tutorial is for one clear result: a Telegram bot running on a VPS with a real webhook, HTTPS, and a process that starts automatically after server reboots.

A TypeScript Telegram bot with grammY
A local app process bound to 127.0.0.1
A systemd service that survives restarts
An Nginx reverse proxy as the only public entry point
A Let's Encrypt certificate issued through Certbot
A Telegram webhook registered with a secret token

Start with a clean directory on the VPS. The app uses TypeScript, grammY, Express as a thin HTTP adapter for webhook handling, and no separate process manager beyond systemd.

Create the project and install dependencies:

BASH
mkdir -p ~/apps/telegram-bot-webhook
cd ~/apps/telegram-bot-webhook

npm init -y
npm pkg set scripts.build="tsc -p tsconfig.json"
npm pkg set scripts.start="node dist/server.js"

npm install grammy express dotenv
npm install -D typescript @types/node @types/express

Create the folder structure:

BASH
mkdir -p src
touch tsconfig.json
touch .env.example
touch src/bot.ts
touch src/server.ts

Use this tsconfig.json:

JSON
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "CommonJS",
    "moduleResolution": "Node",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"]
}

Use this .env.example:

DOTENV
HOST=127.0.0.1
PORT=3000
BOT_TOKEN=replace_with_your_bot_token
WEBHOOK_SECRET=replace_with_a_long_random_secret
WEBHOOK_PATH=/telegram/webhook

Why this setup

It keeps the deployment path readable. The code stays small, the runtime dependencies stay obvious, and the server setup stays close to the way Linux servers are already designed to run services.

The bot logic and the HTTP entry point are split into two files. That keeps the webhook transport separate from the Telegram command logic.

Use this src/bot.ts:

TS
import { Bot } from "grammy";

const botToken = process.env.BOT_TOKEN;

if (!botToken) {
  throw new Error("BOT_TOKEN is required");
}

export const bot = new Bot(botToken);

bot.command("start", async (ctx) => {
  await ctx.reply("Webhook is live. Your TypeScript bot is running.");
});

bot.command("ping", async (ctx) => {
  await ctx.reply("pong");
});

bot.on("message:text", async (ctx) => {
  if (ctx.message.text.startsWith("/")) {
    return;
  }

  await ctx.reply("You said: " + ctx.message.text);
});

Use this src/server.ts:

TS
import "dotenv/config";
import express, { type NextFunction, type Request, type Response } from "express";
import { webhookCallback } from "grammy";
import { bot } from "./bot";

const host = process.env.HOST || "127.0.0.1";
const port = Number(process.env.PORT || 3000);
const webhookPath = process.env.WEBHOOK_PATH || "/telegram/webhook";
const webhookSecret = process.env.WEBHOOK_SECRET;

if (!webhookSecret) {
  throw new Error("WEBHOOK_SECRET is required");
}

const app = express();

app.disable("x-powered-by");
app.use(express.json({ limit: "1mb" }));

app.get("/healthz", (_req: Request, res: Response) => {
  res.status(200).json({ ok: true });
});

function verifyTelegramSecret(req: Request, res: Response, next: NextFunction): void {
  const secret = req.header("x-telegram-bot-api-secret-token");

  if (secret !== webhookSecret) {
    res.status(403).json({ ok: false });
    return;
  }

  next();
}

app.post(
  webhookPath,
  verifyTelegramSecret,
  webhookCallback(bot, "express")
);

app.use((_req: Request, res: Response) => {
  res.status(404).json({ ok: false });
});

async function start(): Promise<void> {
  await bot.init();

  app.listen(port, host, () => {
    console.log("Telegram webhook app listening on http://" + host + ":" + port + webhookPath);
  });
}

start().catch((error) => {
  console.error("Failed to start bot:", error);
  process.exit(1);
});

The code does four important things: it keeps the app on localhost, exposes a health endpoint, verifies Telegram’s secret header before the middleware runs, and handles real bot commands with grammY instead of manually parsing updates.

What is already production-friendly here

The bot process is private, the webhook route is explicit, and Telegram requests are rejected if the secret token does not match.

Before touching Nginx or certificates, make sure the app itself works locally on the VPS.

01

Create the real environment file

Copy .env.example to .env and fill in the bot token and a long random webhook secret.

02

Build the project

Run npm run build so the TypeScript output lands in dist/.

03

Start the built app once

Run npm start and confirm the process starts without configuration errors.

04

Check the local health endpoint

Run curl http://127.0.0.1:3000/healthz and confirm the JSON response is { "ok": true }.

Why this order matters

If the local app is broken, adding Nginx and SSL only makes the failure harder to debug.

Use the Linux service manager that is already part of the server instead of adding PM2 as another moving piece. The service file below is enough for one bot process.

Create a file named telegram-bot-webhook.service:

INI
[Unit]
Description=Telegram Bot Webhook
After=network.target
Wants=network.target

[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/apps/telegram-bot-webhook
Environment=NODE_ENV=production
EnvironmentFile=/home/ubuntu/apps/telegram-bot-webhook/.env
ExecStart=/usr/bin/node /home/ubuntu/apps/telegram-bot-webhook/dist/server.js
Restart=always
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target

Install and start it:

BASH
sudo cp telegram-bot-webhook.service /etc/systemd/system/telegram-bot-webhook.service
sudo systemctl daemon-reload
sudo systemctl enable --now telegram-bot-webhook
sudo systemctl status telegram-bot-webhook

Read the logs:

BASH
journalctl -u telegram-bot-webhook -f

Change the User, WorkingDirectory, EnvironmentFile, and ExecStart values to match your server. Do not copy the /home/ubuntu/... paths blindly.

Deployment target at this point

The bot now runs as a normal Linux service and will come back after a reboot without any extra Node-specific process manager.

Start with the HTTP config first. Certbot can then use the existing Nginx site and add HTTPS for you.

Create an Nginx site such as /etc/nginx/sites-available/bot.example.com:

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

    location = /healthz {
        proxy_pass http://127.0.0.1:3000/healthz;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location = /telegram/webhook {
        proxy_pass http://127.0.0.1:3000/telegram/webhook;
        proxy_http_version 1.1;
        proxy_connect_timeout 5s;
        proxy_read_timeout 60s;
        proxy_send_timeout 60s;
        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;
    }

    location / {
        return 404;
    }
}

Enable the site and reload Nginx:

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

The route in Nginx must match the route in .env and the route you will later register with Telegram. If one of these paths differs, webhook delivery will fail.

Keep the public edge boring

Only Nginx should be reachable from the public internet. The Node.js app stays on 127.0.0.1.

At this point DNS should already point bot.example.com to the VPS public IP, and port 80 should be reachable from the internet.

Install Certbot and prepare the command:

BASH
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/local/bin/certbot

Issue the certificate through the Nginx flow:

BASH
sudo certbot --nginx -d bot.example.com

Test automatic renewal:

BASH
sudo certbot renew --dry-run

Check the public health endpoint:

BASH
curl https://bot.example.com/healthz

Do not register the Telegram webhook before this works over HTTPS.

What Certbot gives you here

A valid certificate, an HTTPS-enabled Nginx site, and a cleaner deployment path than manually assembling TLS files in the first pass.

Now point Telegram at the final public URL. The secret in setWebhook must match the secret stored in .env.

Register the webhook:

BASH
export BOT_TOKEN=replace_with_your_bot_token

curl -X POST "https://api.telegram.org/bot$BOT_TOKEN/setWebhook" \
  -d "url=https://bot.example.com/telegram/webhook" \
  -d "secret_token=replace_with_a_long_random_secret" \
  -d 'allowed_updates=["message"]'

Check the webhook state:

BASH
curl "https://api.telegram.org/bot$BOT_TOKEN/getWebhookInfo"

When this is correct, the url field should show your HTTPS endpoint, and last_error_message should be empty or absent.

The two values that must line up

The webhook URL path and the webhook secret must match across the app, Nginx, and Telegram.

Run these checks in order so you can identify exactly where a failure starts.

The service is active in systemd

Check systemctl status telegram-bot-webhook before blaming Nginx or Telegram.

The local health endpoint works

Check curl http://127.0.0.1:3000/healthz on the server itself.

The public HTTPS health endpoint works

Check curl https://bot.example.com/healthz from outside the app process.

The webhook info is clean

Use getWebhookInfo and look for a matching URL and no recent delivery error.

Telegram commands actually trigger replies

Send /start and /ping to the bot after the webhook is active.

Logs stay readable during a real request

Watch journalctl -u telegram-bot-webhook -f while sending a message to the bot.

What success looks like

A message sent in Telegram reaches the bot through HTTPS, the app replies, and the Linux service stays stable without manual babysitting.

Most deployment failures come from a small set of mismatches.

Running the bot only with npm start in an SSH session instead of creating a real Linux service.

Binding the app to a public interface instead of 127.0.0.1.

Using one webhook path in .env, a different path in Nginx, and a third path in setWebhook.

Using one secret in .env and another secret when calling setWebhook.

Skipping the local health check and trying to debug the whole stack at once.

Registering the webhook before HTTPS is live.

Editing Nginx config and reloading it without running nginx -t first.

Trying to use getUpdates while the outgoing webhook is active.

The pattern behind almost every bug here

Wrong order, wrong path, or wrong secret usually explains the problem.

Why use systemd instead of PM2 in this setup?

Because the server already has a native Linux service manager. For one VPS bot process, systemd is enough to start the app automatically, restart it on failure, and keep it alive after reboots.

Why use grammY instead of handling raw Telegram updates myself?

Because grammY handles the Telegram update model cleanly and gives you proper command and middleware abstractions, so the tutorial stays practical without dropping into raw Bot API plumbing everywhere.

Do I need Nginx if the app already listens on a port?

Yes, for this deployment style. Nginx is the public HTTPS edge, and the app stays private on localhost. That keeps TLS termination and public routing out of the bot process.

Can I use getUpdates and a webhook at the same time?

No. Once an outgoing webhook is active, Telegram does not let the bot receive updates through `getUpdates`.

Can I skip TypeScript and write this in plain JavaScript?

Yes, but TypeScript is the more stable default for a new Node.js bot in 2026 if you expect the project to grow beyond a small demo.

These sources support the deployment choices, webhook behavior, and Linux service flow used in this tutorial.

Reviewed: 08 Apr 2026Applies to: Telegram Bot API webhooksApplies to: Single-server VPS deploymentsApplies to: Node.js and TypeScript applicationsApplies to: grammY-based Telegram botsApplies to: Nginx reverse proxy with Let's EncryptApplies to: Linux servers with systemdTested with: Node.js 24 LTSTested with: TypeScriptTested with: grammY webhook handlingTested with: Express 5 request adapterTested with: Nginx reverse proxyTested with: Certbot for NginxTested with: systemd service unitTested with: Telegram Bot API `setWebhook`Tested with: Telegram Bot API `getWebhookInfo`

PAS7 Studio helps teams move beyond a minimal tutorial bot: production routing, Telegram integrations, admin panels, observability, background jobs, message flows, and server hardening around the full stack.

Related Articles

growth

AI SEO / GEO in 2026: Your Next Customers Aren’t Humans — They’re Agents

Search is shifting from clicks to answers. Bots and AI agents crawl, cite, recommend, and increasingly buy. Learn what AI SEO / GEO means, why classic SEO is no longer enough, and how PAS7 Studio helps brands win visibility in the agentic web.

blogs

The most powerful Apple chip yet? M5 Pro and M5 Max are breaking records

A data-backed March 2026 analysis of Apple M5 Pro and M5 Max. We break down why these chips can credibly be called Apple's most powerful pro laptop silicon, how they compare with M4 Pro, M4 Max, M1 Pro, M1 Max, and how they stack up against Intel and AMD laptop rivals.

blogs

Artemis II and the Code That Carries Humans to the Moon

This article unpacks NASA's Artemis II mission, launched on April 1, 2026, and explains what it really says about modern engineering: flight software, backup logic, simulation, telemetry, human control, and the careful role AI can play in space systems.

telegram-media-saver

Automatic Tagging & Search for Saved Links

Integrate with GDrive/S3/Notion for automatic tagging and fast search via search APIs

Professional development for your business

We create modern web solutions and bots for businesses. Learn how we can help you achieve your goals.