📬 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:
- Runs entirely on my own hardware (a Raspberry Pi in a corner).
- Collects all verification codes into one place – my Telegram messenger.
- Is easy to deploy and update via Docker Compose.
- Respects my privacy – no third‑party service sees my codes.
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
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
- A SIM800L or SIM7600 module (with antenna).
- A USB‑to‑TTL converter (3.3V logic).
- An active SIM card (prepaid works perfectly).
- A Raspberry Pi or any Linux machine with a USB port.
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
- Open Telegram and search for @BotFather.
- Send
/newbotand follow the prompts. You'll get a token (like123456:ABC-DEF1234gh). Write it down. - Search for @userinfobot and send
/startto 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
- Clone / create the project folder with all the files above.
- Fill in your
.envwith real token and chat ID. - Make sure your GSM module is connected and recognized (check with
ls /dev/ttyUSB*). - Run
docker-compose up -d --build. - 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)
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.
??? in TelegramSymptom: 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.
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.
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
- Never commit
.envto version control. Add it to.gitignore. - If your Telegram token leaks, revoke it immediately via @BotFather (
/revoke). - Restrict who can talk to your bot: in @BotFather use
/setjoingroupsto disable group invites, and optionally put a whitelist in your forwarder (check incoming chat ID against allowed ones). Our forwarder only sends to the configuredCHAT_ID, so no one else can read your codes. - Run the container with a non‑root user (add
user: "1000:1000"in docker‑compose) after ensuring dialout group permissions.
🚀 Ideas for Expansion
You now have a fully private SMS‑to‑Telegram bridge. Next you could:
- Multiple SIM cards: Attach several modules via USB hub, run separate containers (or a multi‑threaded listener) and route each to a different chat topic.
- Keyword filtering & auto‑reply: Block spam messages or trigger actions when certain codes arrive (e.g., automatically forward a code to a webhook).
- Multi‑platform notifications: Extend
telegram_forwarder.pyto also push to Discord, Slack, or a custom API. - Persistent SMS archive: Write messages to a SQLite database and serve a small web UI for search.
🏁 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.