📬 Docker Self‑Hosted Private SMS Gateway: Forward Verification Codes to Your Telegram Bot

A practical guide by a DevOps‑minded dev who got tired of reaching for a separate phone. We'll containerize a SIM‑module listener and beam every OTP straight to your pocket via Telegram.

Why Build Your Own Private Gateway?

I have a growing collection of online services that all demand SMS verification. Relying on third‑party disposable number platforms is a privacy risk, and keeping a second phone just for codes is a pain. I wanted a system that:

The solution? A GSM module (SIM800L / SIM7600) plugged into my Pi, a Python script reading SMS, and a Docker container that pushes them to a private Telegram bot. Let's build it.

📐 System Architecture

┌─────────────┐ │ 4G / 2G SIM │ └──────┬──────┘ │ (AT commands over UART) ┌──────▼──────────────────┐ │ SIM800L / SIM7600 Module│ └──────┬──────────────────┘ │ USB‑TTL adapter ┌──────▼──────────────────┐ │ Raspberry Pi / Server │ │ (USB port /dev/ttyUSB0)│ └──────┬──────────────────┘ │ ┌──────▼──────────────────────────┐ │ Docker Container: sms-gateway │ │ ├─ serial_listener.py │ │ ├─ telegram_forwarder.py │ │ └─ queue.Queue (thread‑safe) │ └──────┬──────────────────────────┘ │ HTTPS requests ┌──────▼──────────────┐ │ Telegram Bot API │ └──────┬──────────────┘ │ push notification ┌──────▼──────────────┐ │ Your Phone / PC │ │ (Telegram app) │ └─────────────────────┘

The core idea: a Python serial listener inside Docker continuously reads raw AT responses, decodes incoming SMS, and pushes them into a queue. A second thread (or process) pulls from that queue and sends a formatted message to your Telegram chat via the Bot API.

🛠️ Prerequisites – Hardware & Telegram Setup

Hardware list

Wire VCC (5V) to a separate 2A power supply, GND to ground, TX→RX and RX→TX. If you’re unsure, refer to our previous SIM800L wiring guide – the Docker part starts once the module is answering AT commands on /dev/ttyUSB0.

Create Your Telegram Bot

  1. Open Telegram and search for @BotFather.
  2. Send /newbot and follow the prompts. You'll get a token (like 123456:ABC-DEF1234gh). Write it down.
  3. Search for @userinfobot and send /start to get your Chat ID (a number, e.g., 987654321).

Keep token and chat ID secret; they’ll live in an .env file.

📂 Project Structure & Full Code

Our repository will look like this:

sms-gateway/
├── Dockerfile
├── docker-compose.yml
├── .env
├── requirements.txt
├── serial_listener.py
├── telegram_forwarder.py
└── app.py              # optional health-check endpoint

Dockerfile

FROM python:3.11-slim

WORKDIR /app

# Install system dependencies for serial support
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc && \
    rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

# Run both the serial listener and the Telegram forwarder
# We'll use a shell script or simply chain them; a supervisor would be better.
# For simplicity, we start the serial listener in background and then the forwarder+health check.
CMD ["sh", "-c", "python serial_listener.py & python telegram_forwarder.py & python app.py"]

Note: For production, consider using supervisord or separate containers, but this works for a single‑module setup.

docker-compose.yml

version: '3.8'
services:
  sms-gateway:
    build: .
    container_name: sms-gateway
    restart: unless-stopped
    devices:
      - /dev/ttyUSB0:/dev/ttyUSB0
    environment:
      - TZ=Asia/Shanghai
    env_file:
      - .env
    ports:
      - "5000:5000"   # optional health check
    volumes:
      - ./logs:/app/logs

The devices mapping is critical – it passes the host serial port into the container. Double‑check your actual device path (might be /dev/ttyAMA0 on a Pi without USB‑TTL).

requirements.txt

pyserial==3.5
requests==2.31.0
flask==3.0.0

.env (template)

# .env – do NOT commit this file!
TELEGRAM_BOT_TOKEN=123456:ABC-DEF1234gh
TELEGRAM_CHAT_ID=987654321
SERIAL_PORT=/dev/ttyUSB0
SERIAL_BAUDRATE=115200

serial_listener.py – The SMS Collector

import serial
import time
import re
import queue
import threading
import os
import logging

logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s')

# Global queue shared with forwarder
sms_queue = queue.Queue(maxsize=500)

class SerialListener(threading.Thread):
    def __init__(self, port, baudrate=115200):
        super().__init__(daemon=True)
        self.port = port
        self.baudrate = baudrate
        self.ser = None
        self.buffer = ""

    def run(self):
        while True:
            try:
                self.ser = serial.Serial(self.port, self.baudrate, timeout=1)
                self._init_module()
                self._read_loop()
            except Exception as e:
                logging.error(f"Serial error: {e}, retrying in 5s")
                time.sleep(5)

    def _init_module(self):
        for cmd in ['AT', 'AT+CMGF=1', 'AT+CNMI=2,2,0,0,0', 'AT+CMGD=1,4']:
            self.ser.write((cmd + '\r\n').encode())
            time.sleep(0.3)

    def _read_loop(self):
        while True:
            try:
                if self.ser.in_waiting:
                    chunk = self.ser.read(self.ser.in_waiting).decode('utf-8', errors='ignore')
                    self.buffer += chunk
                    while '\n' in self.buffer:
                        line, self.buffer = self.buffer.split('\n', 1)
                        line = line.strip()
                        if line.startswith('+CMTI:') or line.startswith('+CMT:'):
                            self._fetch_sms()
            except Exception as e:
                logging.error(f"Read error: {e}")
                break

    def _fetch_sms(self):
        self.ser.write(b'AT+CMGR=1\r\n')
        time.sleep(0.8)
        if self.ser.in_waiting:
            raw = self.ser.read(self.ser.in_waiting).decode('utf-8', errors='ignore')
            match = re.search(r'\+CMGR:.*?"(.*?)".*?\n(.*)', raw, re.DOTALL)
            if match:
                sender = match.group(1)
                body = match.group(2).strip()
                # Handle UCS‑2 hex
                if body and all(c in '0123456789ABCDEFabcdef ' for c in body):
                    try:
                        hex_str = body.replace(' ', '')
                        body = bytes.fromhex(hex_str).decode('utf-16-be')
                    except:
                        pass
                logging.info(f"SMS from {sender}: {body}")
                if not sms_queue.full():
                    sms_queue.put({'sender': sender, 'text': body})
            self.ser.write(b'AT+CMGD=1\r\n')  # delete to avoid memory full

if __name__ == '__main__':
    port = os.environ.get('SERIAL_PORT', '/dev/ttyUSB0')
    baud = int(os.environ.get('SERIAL_BAUDRATE', '115200'))
    listener = SerialListener(port, baud)
    listener.start()
    # Keep main thread alive
    while True:
        time.sleep(60)

telegram_forwarder.py – The Telegram Dispatcher

import os
import time
import requests
import logging
from serial_listener import sms_queue

logging.basicConfig(level=logging.INFO)

TOKEN = os.environ['TELEGRAM_BOT_TOKEN']
CHAT_ID = os.environ['TELEGRAM_CHAT_ID']
BASE_URL = f'https://api.telegram.org/bot{TOKEN}'

def send_message(text):
    url = f'{BASE_URL}/sendMessage'
    payload = {'chat_id': CHAT_ID, 'text': text, 'parse_mode': 'HTML'}
    try:
        resp = requests.post(url, json=payload, timeout=10)
        if resp.status_code != 200:
            logging.error(f"Telegram API error: {resp.text}")
    except Exception as e:
        logging.error(f"Telegram send failed: {e}")

def main():
    logging.info("Telegram forwarder started")
    while True:
        while not sms_queue.empty():
            msg = sms_queue.get_nowait()
            sender = msg.get('sender', 'Unknown')
            body = msg.get('text', '')
            # Construct a nice message
            text = f"📩 New SMS\nFrom: {sender}\n\n📝 {body}"
            send_message(text)
            # Respect rate limits (30 msg/sec max, but we sleep a bit to be safe)
            time.sleep(0.1)
        time.sleep(1)

if __name__ == '__main__':
    main()

app.py – Health Check (optional)

from flask import Flask
app = Flask(__name__)

@app.route('/health')
def health():
    return 'OK'

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

🚀 Deployment & First Message

  1. Clone / create the project folder with all the files above.
  2. Fill in your .env with real token and chat ID.
  3. Make sure your GSM module is connected and recognized (check with ls /dev/ttyUSB*).
  4. Run docker-compose up -d --build.
  5. Send an SMS to your module’s number. Within seconds, your Telegram should ping with the message and extracted content.

Check container logs with docker logs -f sms-gateway. You'll see the AT commands and the SMS text appear.

💥 Four Docker‑Specific Pitfalls I Hit (And Fixed)

1. Container can’t open /dev/ttyUSB0
Symptom: serial.serialutil.SerialException: [Errno 2] could not open port /dev/ttyUSB0.
Root cause: The device isn't mapped, or the container user lacks permission.
Fix: Verify the devices block in docker-compose.yml. If the host uses a different name (e.g., /dev/ttyAMA0), adjust both the mapping and the SERIAL_PORT env variable. Also, on some systems you may need to add group_add: dialout or run as privileged (not recommended). A quick test: docker exec -it sms-gateway ls -l /dev/ttyUSB0.
2. Chinese SMS arrives as ??? in Telegram
Symptom: The module receives Chinese perfectly on serial console, but your bot receives garbled text.
Diagnosis: The SIM800L delivers UCS‑2 messages as hex strings in text mode. Our serial_listener.py already includes a UCS‑2 decoder. If the problem persists, check that the hex conversion runs (the if body and all(...) block). Also ensure Telegram uses parse_mode='HTML' – without it, some characters might break.
3. Telegram rate limits & 429 errors
Symptom: You receive a burst of 10 SMS in one second, and only the first couple appear. Container logs show HTTP 429.
Fix: The Bot API allows ~30 messages per second, but bursts can still trigger throttling. We added a small time.sleep(0.1) between sends. For high‑traffic numbers, implement a more robust token bucket or use a queue with controlled draining. Also ensure your bot isn't sending duplicate messages.
4. Container restarts but loses serial connection
Symptom: After a Docker restart (or host reboot), the gateway hangs with no new SMS.
Fix: Our SerialListener.run() already loops on exceptions – but if the serial port disappears entirely (USB enumeration delay), the loop will keep retrying until the device reappears. Ensure restart: unless-stopped is set. Also, consider a separate health‑check endpoint that verifies the serial listener is alive; if not, trigger an unhealthy state and let Docker restart the container.

🔐 Security Hardening

🚀 Ideas for Expansion

You now have a fully private SMS‑to‑Telegram bridge. Next you could:

🏁 Final Words

With a handful of components and a little Docker magic, you’ve turned a cheap GSM module into a personal SMS relay that beams every verification code directly to your Telegram. No more peeking at a secondary phone, and no more relying on third‑party services that might read your messages. It’s private, it’s elegant, and it scratches that deep itch every tech tinkerer knows.

Now go connect that antenna, fire up the container, and let the OTP rain begin.