Developer docs
English

Running a server

Server installation

How to install and run a Pabal server with Docker Compose on a single server that's reachable from the internet.

This is how to install a Pabal server on a single server that's reachable from the internet. Docker Compose brings up three containers — the Pabal server · PostgreSQL · Caddy (HTTPS) — and all data that must not be lost is bind-mounted to host directories. Just follow the steps in order from the top.

Example values used on this page

Domain pabal.me, server public IP 203.0.113.10 (an address reserved for documentation — read it as your real IP), source in /opt/pabal, data in /srv/pabal, operating system Ubuntu 24.04 LTS. The files you need are in the deploy/ folder of the repository.

At a glance

Internet docker compose · ssemiya-net /srv/pabal (bind mounts) Pabal appPabal.app Browsers · botsSite · Bot API OperatorSSH pabal-server MTProto :8443 Web · API · admin :8080 uid 1000 · JRE 21 Webhooks → bots pabal-caddy:80 · :443 auto TLS pabal-postgres 16Internal only · no ports pabal_server/keys · RSA key pabal_server/data · photos, cfg pabal_server/logs postgres_data/ · all chats caddy/ · certificates backups/ · backup.sh MTProto :8443 (own encryption) HTTPS :443 SSH tunnel → 127.0.0.1:8080/admin/
One server, three containers, and everything that must survive in /srv/pabal

About this diagram

  • Three areas: users on the internet → three containers on the production server → bind directories on the host's disk. The containers can be deleted and recreated at any time; everything that has to survive is in /srv/pabal.
  • There are only two entrances: the app connects directly to the server on the MTProto port (it encrypts on its own, so no HTTPS is needed), while the website, the docs and the Bot API go through Caddy's port 443. Caddy blocks the management paths (/admin, /health …) and passes on only the rest.
  • The red dashed line is the operator's own path. The admin page is open only on the server's 127.0.0.1:8080, so the only way in is through an SSH tunnel. PostgreSQL has no port open to the outside at all.
  • The two red boxes are the most important data. The RSA key in pabal_server/keys is built into the app, so if you lose it you have to redistribute every app; postgres_data/ holds every account and conversation. These are your top backup priorities.
  • Gray arrows are storage: the server writes keys, photos, settings and logs, PostgreSQL writes events, and Caddy writes certificates, each to its own directory.

What you need

ItemDetails
ServerUbuntu 24.04 LTS, starting with 2 vCPUs · 4 GB RAM · 40 GB SSD. As users and messages grow, add memory first
Public IPv4A static IP. The app is built with the server's address as an IP, and the server also tells the app its IP
DomainOne domain whose DNS you can change (an A record)
A way to deliver sign-up codesAn SMS (Twilio · Solapi · webhook) or email (SMTP) account
MacA Mac to build the apps for this server (plus a Windows PC if you also want the Windows app)
Firebase project (optional)For sending push notifications to the Android app. You can create one for free with a Google account (step 10)

Ports

PortWhoOpen toDescription
22/tcpOperatorOperator IPs only, recommendedSSH
80/tcpCaddyEveryoneCertificate issuance · redirect to HTTPS
443/tcp, 443/udpCaddyEveryoneWebsite · docs · Bot API (udp is HTTP/3)
8443/tcpServerEveryoneApp connections (MTProto)
8080/tcpServerKeep closedAdmin page · health check — only on the server's 127.0.0.1
5432/tcpPostgreSQLKeep closedOnly on the containers' internal network
Why isn't the app port 443?

Port 443 is used by the website (HTTPS). MTProto is neither HTTP nor TLS, so the two can't share one port. If you want the app on 443 too, because company or school networks block unusual ports, get a second IP and give that IP's port 443 to the server (in .env, set PUBLIC_IP to the second IP and MTPROTO_PORT=443, and split them by adding the IPs to the ports in the compose file). The server tells the app "connect to this port on this IP", so keep the app port the same inside and outside the container.

1. Prepare the server

sudo apt update && sudo apt -y upgrade
timedatectl                      # check for "System clock synchronized: yes"

Time synchronization is essential. MTProto message IDs are derived from the time, so if the server's clock drifts, the app keeps reconnecting. If it says no, run sudo timedatectl set-ntp true.

# Firewall
sudo ufw allow OpenSSH           # if possible: sudo ufw allow from <operator IP> to any port 22
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw allow 8443/tcp
sudo ufw enable

# Docker (official install script)
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER     # after logging in again, docker works without sudo
docker compose version            # v2 or later
Docker and ufw

Ports Docker opens (ports:) are opened regardless of ufw rules. That's why the compose file opens only the public ports (80, 443, 8443), opens the admin port only as 127.0.0.1:8080, and doesn't open PostgreSQL at all. Keep this in mind when you edit ports:.

2. Get the source

sudo mkdir -p /opt/pabal && sudo chown $USER: /opt/pabal
git clone <repository URL> /opt/pabal

If you copy it from a development computer, don't send the keys, data or logs (rsync --exclude 'keys/' --exclude 'data/' --exclude 'logs/' --exclude '**/target/'). The production server creates its own RSA key the first time it starts — if you use a development key in production, that development computer can decrypt production traffic.

3. Data directories (bind mounts)

sudo mkdir -p /srv/pabal/{postgres_data,pabal_server/keys,pabal_server/data,pabal_server/logs,caddy/data,caddy/config,backups}
sudo chown -R 1000:1000 /srv/pabal/pabal_server        # the server container runs as uid 1000
sudo chown -R $USER: /srv/pabal/backups
sudo chmod 700 /srv/pabal/pabal_server/keys /srv/pabal/backups
DirectoryIn the containerWhat's in itIf you lose it
pabal_server/keys/app/keysprivate.pem (the server's RSA private key), private.pem.pub, (optional) firebase-service-account.json (the Firebase secret key for Android push)Rebuild and redistribute every app (for the Firebase key, just download a new one from the console)
pabal_server/data/app/dataPhotos (media/), admin-token, operations.json (sign-up code, SMS and SMTP settings, including secrets)Photos and operational settings
pabal_server/logs/app/logsServer logs (compressed daily)Only the logs
postgres_data/var/lib/postgresql/dataEverything: accounts, conversations, messages, sign-ins, bots, webhook settingsThe whole service
caddy/data, caddy/config/data, /configHTTPS certificatesReissued automatically
backups(host only)Output of backup.sh—

4. The .env settings file

cd /opt/pabal/deploy
cp .env.example .env
chmod 600 .env
openssl rand -base64 30 | tr -d '/+=' | cut -c1-32     # put the output in POSTGRES_PASSWORD
nano .env
DOMAIN=pabal.me
PUBLIC_IP=203.0.113.10
POSTGRES_PASSWORD=(the value generated above)
PABAL_HOME=/srv/pabal
PABAL_SOURCE=..
PABAL_NETWORK=ssemiya-net
MTPROTO_PORT=8443
HTTP_PORT=80
HTTPS_PORT=443
ADMIN_PORT=8080
ADMIN_TOKEN=
JAVA_OPTS="-Xms512m -Xmx2g -XX:+UseG1GC -XX:MaxGCPauseMillis=100"
FCM_CREDENTIALS=
FCM_PROJECT_ID=
PUSH_PREVIEWS=true
ValueWhat to putNotes
DOMAINThe website's domainLinks the app creates (pabal.me/username), addresses in the docs and link previews all use this address
PUBLIC_IPThe server's public IPv4In the cloud, the public IP shown in the console (not the private IP inside the server)
POSTGRES_PASSWORDA random valueSet it before the first start. PostgreSQL only uses this value when it first creates the data directory
PABAL_HOME/srv/pabalThe directory from step 3
PABAL_SOURCE..The repository the server image is built from: .. when the compose file stays in the repository’s deploy/, otherwise the path to the source
PABAL_NETWORKssemiya-netThe Docker network the three containers join. Create it beforehand (step 6)
MTPROTO_PORT8443If you change it, change the firewall and the app build too
ADMIN_TOKENLeave emptyIf empty, it's created in pabal_server/data/admin-token on first start. To set it yourself, use 16 or more characters
JAVA_OPTSThe defaultIf memory runs short, raise -Xmx (up to about half the server's memory)
FCM_CREDENTIALSLeave emptyAndroid push notifications. To turn them on, set /app/keys/firebase-service-account.json (step 10). If empty, the server runs without push
FCM_PROJECT_IDLeave emptyOnly when sending to a Firebase project other than the key file's project_id
PUSH_PREVIEWStrueWith false, notifications show only the sender, without the message text

The production compose file starts the server with test numbers turned off (TELEGRAM_TEST_NUMBERS=false). The full list of settings the server reads is in Administration and settings — environment variables.

5. DNS

NameTypeValue
pabal.meA203.0.113.10
www.pabal.meA203.0.113.10
dig +short pabal.me        # should print 203.0.113.10 (takes minutes to hours to propagate)

You can start the server before DNS points to it. Caddy keeps retrying on its own until it gets a certificate.

6. Build and start

cd /opt/pabal/deploy
docker network inspect ssemiya-net >/dev/null 2>&1 || docker network create ssemiya-net   # once
docker compose up -d --build       # a few minutes the first time (Maven downloads libraries)
docker compose ps                  # pabal-server, pabal-postgres, pabal-caddy running (healthy)
docker compose logs -f pabal-server      # leave with Ctrl+C

On the first start, lines like these appear in the log.

WARN  ServerKeys - Generated a new RSA key at /app/keys/private.pem (fingerprint -3898654385185406269). Clients must embed /app/keys/private.pem.pub
INFO  TelegramServer - JDBC persistence active (jdbc:postgresql://pabal-postgres:5432/pabal)
INFO  BotFather - BotFather is user 100000
INFO  AdminToken - Created the admin token in /app/data/admin-token
INFO  TelegramServer - Website: http://0.0.0.0:8080/ (published at https://pabal.me/)
  • The first line is a WARN, but that's normal (a one-time notice that the key is being created). From then on you'll see Loaded RSA key … (fingerprint …). The fingerprint is different for every server.
  • The server creates the tables itself when it starts.

7. Check

# From outside (your own computer)
curl -sI https://pabal.me/ | head -1                              # HTTP/2 200
curl -s -o /dev/null -w '%{http_code}\n' https://pabal.me/docs/    # 200 — these docs
curl -s -o /dev/null -w '%{http_code}\n' https://pabal.me/admin/   # 404 — the admin page isn't public
curl -s https://pabal.me/docs/server-key.pem | head -1             # -----BEGIN RSA PUBLIC KEY-----
nc -vz 203.0.113.10 8443                                          # succeeded — the app port

# On the server
curl -s http://127.0.0.1:8080/health          # {"status":"UP",…}
ls -l /srv/pabal/pabal_server/keys                  # private.pem(600), private.pem.pub

8. The admin page and first settings

# On your computer (leave it running)
ssh -N -L 8080:127.0.0.1:8080 <user>@203.0.113.10

# The token, on the server
sudo cat /srv/pabal/pabal_server/data/admin-token

Open http://localhost:8080/admin/ in a browser and enter the token. Settings to make before going live (details in Administration and settings):

  1. Set the code delivery method (코드 전달 방식) to SMS or email. With the "Admin page" (관리 화면) method, the operator has to pass on every code by hand.
  2. Enter the SMS or email (SMTP) (이메일 (SMTP)) values and save → use Send test (테스트 발송) to check that messages really arrive.
  3. Check that test numbers (테스트 번호) are off. If they're on, anyone can sign in with a +99966… number.
  4. If you like, start with Allow new sign-ups (새 가입 허용) turned off, and let in only the people you invite first.

9. Connect the app to this server

The app is built with the server IP, port and public key inside it. It only connects to this server once it's rebuilt with this server's public key. On a Mac:

# 1. This server's public key (the public key, not the private key — you can also download it from the docs site)
curl -s -o keys/production.pem.pub https://pabal.me/docs/server-key.pem

# 2. Point the app source at this server + apply the branding
python3 scripts/tdesktop/point_to_server.py ~/Developer/tdesktop \
    --host 203.0.113.10 --port 8443 --key keys/production.pem.pub
python3 scripts/tdesktop/apply_branding.py ~/Developer/tdesktop

Then do step 5 (configure) and step 6 (build) of docs/tdesktop-build-guide.md in the repository. Copy the resulting out/Debug/Pabal.app under another name — rebuilding the same source for a different server overwrites it.

Before you hand the app to other people

The current build is an unsigned, unnotarized debug build, so people who receive it have to right-click → Open the first time. A debug build keeps its data in a folder next to the app (tdata/), and if it can't write there it uses the same data folder as the real Telegram Desktop. Tell people to keep the app in their user folder (for example, ~/Applications/Pabal/). A release build for general distribution (a separate data folder, Apple signing and notarization) is a separate job.

The Android, iPhone and Windows apps follow the same approach (a script that puts in the server address and public key → build), described in docs/mobile-apps-guide.md in the repository. To include push notifications in the Android app, you need the Firebase app configuration from step 10 before you build.

10. Android push notifications (optional)

Android soon puts an app to sleep once it's off the screen. Without push, messages appear and calls ring only while the app is open. Push is sent through Google's Firebase Cloud Messaging (FCM) (free of charge), and the server encrypts the notification's contents with a key only that phone has (the sender's name, a message preview), so Google can't read them.

Firebase project (operator's) Build · install Phone · Google google-services.json Config built into the app Inside the APK · not a secret Service account key firebase-service-account.json Secret · server only Android app build apply_branding.py → APK pabal-server keys/ · chmod 600 FCM_CREDENTIALS Encrypts with the phone's key Pabal app on the phone Registers this phone on first open Google FCM Relays ciphertext · can't read it ① Register phone ② Encrypted push ③ Wake → notify
Two files go to two different places, and notifications travel encrypted

About this diagram

  • Three areas: the Firebase project the operator creates → where the app is built and the server runs → the phone and Google. You get two files from one Firebase project.
  • Gray arrows are one-time setup. The green google-services.json goes inside the app and ships in every APK, so it's not a secret; the red service account key is a secret kept only in the server's keys/. Both files must come from the same project.
  • Blue arrows are the path a notification takes: ① when the app is first opened, the phone registers its FCM token and a key only that phone has with the server; ② when a new message or call comes in, the server encrypts the notification with that key and hands it to FCM; ③ FCM wakes the phone, and the app decrypts it, shows the notification, and reconnects to the server to fetch what it missed.
  • What Google sees is only delivery information such as the device token, the time sent and the priority. The sender's name and the message are encrypted, so it can't read them.
  • No push is sent to a phone that's looking at the app — it's already receiving everything over its connection. Push for chats with notifications turned off goes out at a non-urgent priority, and the app filters it out itself.
The Firebase project and the app are a pair

With the server's key you can only send push to apps built with the same Firebase project. An app someone else built (for example, one downloaded from Pabal.me) is tied to their Firebase project, and was built with their server's address and public key in the first place. Build the apps for your server yourself, and create the Firebase project yourself too.

Create a Firebase project

  1. In the Firebase console, choose Add project. Any name will do, and you don't need Google Analytics.
  2. Project settings → General → Your apps → Add app → Android. For the package name, enter me.pabal.android (the name the app build script gives the app). The certificate fingerprint (SHA-1) isn't needed for push.
  3. Download that app's google-services.json. It's configuration that goes into the app, not a secret.
  4. Under Project settings → Service accounts → Generate new private key, download the JSON file and rename it to firebase-service-account.json. This file is a secret — don't put its contents in a repository, a chat or .env.
  5. Under Project settings → Cloud Messaging, check that Firebase Cloud Messaging API (V1) is enabled (it usually is for new projects).

Add the configuration to the app

Before building the Android app, put google-services.json where the build script looks for it. The script checks the file (that me.pabal.android is registered in the project, and that you haven't handed it the secret key by mistake) and then adds it to the app source.

mkdir -p ~/.pabal/android
cp ~/Downloads/google-services.json ~/.pabal/android/     # to keep it elsewhere, give apply_branding.py --google-services <path>
python3 scripts/android/point_to_server.py <Android source> \
    --host 203.0.113.10 --port 8443 --key keys/production.pem.pub
python3 scripts/android/apply_branding.py <Android source>
# At the end you should see "Firebase: project <project ID>". If you see pabal-placeholder, the app will have no push

Then build it following the "Android app" section of docs/mobile-apps-guide.md in the repository. Phones that were using the app from before push was added also have to update to the newly built app to get notifications.

Put the secret key on the server

sudo cp firebase-service-account.json /srv/pabal/pabal_server/keys/
sudo chown 1000:1000 /srv/pabal/pabal_server/keys/firebase-service-account.json    # the user inside the server container
sudo chmod 600 /srv/pabal/pabal_server/keys/firebase-service-account.json

Add one line to .env. Inside the container, pabal_server/keys is /app/keys.

FCM_CREDENTIALS=/app/keys/firebase-service-account.json
docker compose up -d pabal-server
docker compose logs pabal-server | grep -i push
INFO  TelegramServer - Push notifications on: Firebase project <project ID>, 0 device registrations, message text included (encrypted per device)
  • If you see this line, push is on. If the file can't be read or its format is wrong, Push notifications are off: … gives the reason (the file's contents never appear in the log).
  • With PUSH_PREVIEWS=false, the line ends with message text left out, and notifications show only the sender, without the message text.
  • The container must be able to make outgoing connections to oauth2.googleapis.com and fcm.googleapis.com (443).

Check

  1. Install (or update) the newly built app on a phone and open it once. The server log shows Push device registered: user … type 2 token … (push key 256 bytes). The token appears only as a fingerprint.
  2. Swipe the app away in the recent apps list. Don't use "Force stop" in Settings — Android won't deliver push to the app until you open it yourself again.
  3. Send it a message from another account: the log shows Push MESSAGE_TEXT to user …: sent and a notification appears on the phone.
  • Privacy policy: in the admin page (관리 화면) → Settings (설정) → Operator information (운영자 정보), add Google LLC (Firebase Cloud Messaging, United States) under Processors (처리 위탁) and Transfers abroad (국외 이전). The policy text already says that notifications go through FCM. Put the same in Google Play's "Data safety" form too.
  • If the key leaks, delete it in the Firebase console, download a new one, replace the file and restart the server. The app doesn't need to be rebuilt.
iPhone and desktop

iPhone push is turned on separately, not with Firebase but with an APNs key (.p8) from an Apple developer account — see step 11 right below. The macOS and Windows apps have no push; they receive messages while they're running.

11. iPhone push notifications (optional)

The iPhone, too, soon stops an app once it's off the screen. Without push, messages and calls arrive only while the app is open. Push is sent through the Apple Push Notification service (APNs), and, just as on Android, the notification's contents are encrypted with a key only that iPhone has. The only notification text Apple can see is the app name and a line with no content, such as "You have a new notification"; the Notification Service Extension inside the iPhone app decrypts the ciphertext and turns it into the sender and the message.

  • Registration: every time the app starts, it registers two things for each account — one for notifications (type 1) and one for calls (VoIP, type 9). Along with the registration, the server remembers the app's language (the language the outer text is written in) and its build number.
  • Messages go as notifications. Nothing is sent for muted chats. What was read or deleted on another device is announced with a silent background push, so the app clears the notifications that were showing.
  • Calls, when sent over VoIP, bring up the CallKit call screen even on a locked phone. But an app that receives a VoIP push must report the call to CallKit right away; if it can't, iOS kills the app, and if that keeps happening, iOS stops delivering VoIP pushes to it. So VoIP is turned on with APNS_VOIP_MIN_BUILD only from builds made to do that; before then, calls go as notifications too.
The APNs key belongs to the team that signed the app

Unlike Firebase, there is no configuration file to put in the app — the app is already built to use APNs (notification permission, App Group, Notification Service Extension), so all you need is the key on the server. But the server's key can send push only to apps signed by the team that created the key and whose bundle ID is the same as APNS_TOPIC. If you signed your server's iPhone app with your own Apple developer account, create the APNs key in that account too.

Create an APNs key

  1. Apple Developer → Certificates, IDs & Profiles → Keys → +. It must be the account of the team that signed the app.
  2. Enter a name and check Apple Push Notifications service (APNs). Set the environment to Sandbox & Production and the scope to the whole team (Team Scoped) — the server sends to two places, notifications and VoIP.
  3. Register → Download. AuthKey_<key ID>.p8 can be downloaded only once. This file is a secret — don't put its contents in a repository, a chat or .env.

Put the key on the server

sudo cp AuthKey_<key ID>.p8 /srv/pabal/pabal_server/keys/
sudo chown 1000:1000 /srv/pabal/pabal_server/keys/AuthKey_<key ID>.p8    # the user inside the server container
sudo chmod 600 /srv/pabal/pabal_server/keys/AuthKey_<key ID>.p8

Add these to .env. Inside the container, pabal_server/keys is /app/keys.

APNS_KEY=/app/keys/AuthKey_<key ID>.p8
APNS_TEAM_ID=4Z8R468M2D
APNS_TOPIC=me.pabal.ios
APNS_MIN_BUILD=4
APNS_VOIP_MIN_BUILD=
ValueWhat to putNotes
APNS_KEYThe key file's pathThe key ID is read from the file name. If you gave the file another name, put the key ID in APNS_KEY_ID
APNS_TEAM_IDThe ID of the team that signed the appFor Pabal's app, 4Z8R468M2D. For an app you built and signed yourself, that team's ID
APNS_TOPICThe app's bundle IDFor Pabal's app, me.pabal.ios. Calls (VoIP) go to this with .voip added to the end
APNS_MIN_BUILD4Nothing is sent to builds lower than this (0 for all). 4 leaves out Pabal 1.0 (3), which was made before push was tested
APNS_VOIP_MIN_BUILDLeave emptyIf empty, calls go as notifications too. Put the number of the first build that reports calls to CallKit right away, and from that build on, calls get the CallKit call screen
docker compose up -d pabal-server
docker compose logs pabal-server | grep -i push
INFO  TelegramServer - iPhone push notifications on: APNs key <key ID> of team 4Z8R468M2D, topic me.pabal.ios, builds from 4, calls as alerts (TELEGRAM_APNS_VOIP_MIN_BUILD not set)
  • If you see this line, it's on. If the key file can't be read or its format is wrong, iPhone push notifications are off: … gives the reason (the file's contents never appear in the log). It is turned on and off separately from Android (FCM) — you can turn on just one of them.
  • The container must be able to make outgoing connections to api.push.apple.com (443, HTTP/2). Debug builds installed on a device straight from Xcode go to api.sandbox.push.apple.com (the app says so when it registers).

Check

  1. On a real iPhone, open the app once and allow notifications. The server log shows Push device registered: user … type 1 token … (push key 256 bytes, build …) and a type 9 line. You can't check this with the Simulator — it has neither APNs tokens nor CallKit.
  2. Swipe the app up to close it.
  3. Send it a message from another account: the log shows Push MESSAGE_TEXT to user …: sent, and the sender and the message appear on the lock screen.
  • Privacy policy: in the admin page (관리 화면) → Settings (설정) → Operator information (운영자 정보), add Apple Inc. (Apple Push Notification service, United States) under Processors (처리 위탁) and Transfers abroad (국외 이전). The policy text already says that notifications go through APNs. In App Store Connect's app privacy questionnaire, check the Device ID (push token) item as well.
  • If the key leaks, Revoke it in the developer account, create a new one, replace the file and restart the server. The app doesn't need to be rebuilt.

12. Voice bots (optional)

Voice bots let people call a bot from the app and have an AI voice model answer (the explanation for bot owners is in Voice bots). Two more components have to be attached to the server, and the feature turns on only once the legal documents and app builds are in place. It is off by default.

Voice is turned on with an add-on compose file

The voice components are not in deploy/docker-compose.yml but in the add-on file deploy/docker-compose.voice.yml — the server's voice settings and the link-only network, the gateway pabal-voice (profile voice) and the watch's audio edge pabal-voice-edge (profile voice-watch). Unless .env names that file, there are no voice settings at all. The setup has been run with the echo engine, but voice has not yet been turned on on a production server, and no voice-bot call has yet been made end to end through a production relay (TURN) — the end-to-end tests used a test relay inside a Docker network (scripts/voice-e2e/run.sh). Before turning it on, the repository's deploy/voice_readiness.py checks what is missing. Whether the conditions for public launch (legal review, the provider's written answer, releasing the apps) are met is for the operator to judge; the code enforces only some of them.

ComponentWhat it doesWithout it
pabal-voice (the gateway, image 0.11.0)Becomes one end of the call in place of the bot, does the key exchange, and exchanges audio with the AI model. Python 3.12 + ntgcalls. It talks to the server over the link (a WebSocket that is authenticated but not encrypted)Every bot call is busy (no_gateway)
coturn (the call relay)Bot calls use the relay only: both the app and the gateway turn off direct connections and send audio only through the relay. The same relay as for calls between people (TURN_SECRET)Every bot call is busy (no_relay), and the gateway's connection is refused too (4008)
Vertex AI keyA Google Cloud service account JSON key. Only the gateway's engine worker process opens itEcho only, with no model (VOICE_ENGINE=echo; the audio never leaves the machine)
(Optional) Master keyThe key that wraps bot owners' own keys and stream-mode secretsOwn keys and stream mode off
(Optional) MCP componentspabal-mcp · the signer · the egress proxy (below)MCP servers off

Conditions for turning on

What the server looks forWithout it
The link-secret file (TELEGRAM_VOICE_LINK_SECRET_FILE, 32 bytes)Voice off — the link port is not even opened
The call relay (TELEGRAM_TURN_HOST·TELEGRAM_TURN_SECRET)Every bot call is busy
The id="bot-calls" section of the site's privacy policy (both ko and en) — the apps' confirmation sheet points hereNo bot takes calls (ERROR log). A draft is at docs/voice-bots/p0/privacy-*.draft.html
The id="voice-bot-owners" section of the terms of serviceNobody can accept the bot owner terms → transcripts, own keys, stream mode and MCP are all unavailable
The policy's id="bot-calls-stream" · id="bot-calls-mcp" sectionsStream mode · MCP off
Allowing bots on the admin page (by default allowed bots only) and public launch (by default testers only; launching requires typing a confirmation phrase)Bots that are not allowed take no calls, and before public launch only testers can call
The caller's app build: desktop pabal-bc2 (while the confirmation sheet is only in Korean and English, pabal-bc1 too) · Android 62151 · iPhone 5 or later · Apple Watch 1 or later (TELEGRAM_VOICE_MIN_BUILD_*)That person has no call button, and calls are busy
(Optional) Apple Watch: the server's watch API (TELEGRAM_WATCH_API_PORT) and audio address (TELEGRAM_WATCH_AUDIO_URL), the audio edge pabal-voice-edge, a gateway that announces direct (below)Calls can't be made from the watch (the phone and computer apps are unaffected)
(Optional) Keeping the conversation: the policy's id="bot-calls-keep-text" section, a gateway that announces keep_text (VOICE_KEEP_TEXT, below)Even if the checkbox is chosen, the conversation is not kept
(Optional) Calls from bots: the policy's id="bot-calls-outbound" section and the terms' id="voice-bot-owners-outbound" section, the evidence-key folder, the outbound minimum builds, a gateway that announces outbound, the global switch on the admin page (below)No bot can call anyone

Only on a test server (TELEGRAM_TEST_NUMBERS=true) can you skip the legal-section check with TELEGRAM_VOICE_NOTICE_CHECK=off, and let anyone test with TELEGRAM_VOICE_BOTS (* for all bots) and TELEGRAM_VOICE_TESTERS=*. Do not use these in production — a production server ignores TELEGRAM_VOICE_BOTS with a warning.

Call relay (coturn)

  • On a Linux server, use pabal-turn from the compose file (COMPOSE_PROFILES=turn). Set TURN_SECRET in .env (openssl rand -hex 32), and open TURN_PORT (3478, udp and tcp) and TURN_MIN_PORT–TURN_MAX_PORT (49160–49200, udp) on the router and firewall. If you widen the range, change the router and .env together.
  • Do not run coturn as a container on Docker Desktop for macOS. Every caller appears as 192.168.65.1, so relay permissions never match, and all the audio is dropped even though everything looks fine. On a Mac server, install coturn directly (deploy/turnserver.conf.example, docs/turn-relay-guide.md).
  • The relay address given to the gateway is the server's LAN address: put the same value in the server's TELEGRAM_VOICE_TURN_HOST and the gateway's VOICE_TURN_HOST. host.docker.internal did not reach it, and with 127.0.0.1 allocations did not complete. The apps still get the public address (TURN_HOST).
  • From outside your network, check with deploy/check_relay.py --data that audio actually crosses the relay. Sometimes STUN answers and allocations succeed, yet the audio does not get through.

Vertex AI key

  1. In a Google Cloud project, enable the Agent Platform API (formerly the Vertex AI API, aiplatform.googleapis.com). A billing account must be linked. The Gemini Developer API (AI Studio keys) is not used.
  2. Create a service account and give it only one role, roles/aiplatform.user (to narrow it further, a custom role with just the aiplatform.endpoints.predict permission). Create the production key separately from the development one.
  3. Download the JSON key and keep it outside the repository and .env, for example at /srv/pabal/pabal_voice/secrets/vertex-sa.json, readable only by the gateway's user (on Linux: chown 10001:10001, chmod 400). If anyone can read it, the gateway refuses to start.
  4. The realtime model exists only in us-central1 (Iowa, USA) (checked 2026-09-26). The privacy policy and the confirmation sheet must say that call audio goes to the United States, and the server gives calls to a gateway only if the region on the apps' confirmation sheet matches the gateway's region (VOICE_VERTEX_REGION).
  5. To do: a budget alert, the concurrent-session quota (twice the gateway capacity), and Vertex's data caching (when on, it keeps inputs and outputs in memory for up to 24 hours) — the confirmation sheet says "Google does not keep the audio or text after producing the replies", so until caching is turned off, that statement is not accurate. Recommended: turn off data caching before public launch (section 9 of the repository's docs/voice-bots/vertex-setup.md) and narrow the key's role to the single predict permission (section 10 of the same document).

The server and the gateway read the same link secret from a file. The link is not encrypted, so put it only on a dedicated network that just these two containers join — not on ssemiya-net, which is shared with other stacks, or on the LAN. Do not publish the port.

(umask 077; openssl rand -hex 32 > /tmp/voice-link.secret)
sudo install -m 400 -o 1000 /tmp/voice-link.secret /srv/pabal/pabal_server/keys/voice-link.secret   # the server's copy (uid 1000)
sudo install -d -m 700 -o 10001 /srv/pabal/pabal_voice/secrets
sudo install -m 400 -o 10001 /tmp/voice-link.secret /srv/pabal/pabal_voice/secrets/voice-link.secret # the gateway's copy (uid 10001)
shred -u /tmp/voice-link.secret

These go in .env. With them, the add-on file gives pabal-server its voice settings (TELEGRAM_VOICE_*) and the link network.

COMPOSE_FILE=docker-compose.yml:docker-compose.voice.yml
COMPOSE_PROFILES=voice                              # with any other profiles (turn, …), comma-separated
VOICE_LINK_SECRET_FILE=/app/keys/voice-link.secret  # the path inside the container; empty: voice off
VOICE_TURN_HOST=192.168.1.64                        # the server's LAN address (where coturn listens)
VOICE_TESTERS=100001                                # ids of the people who may call before the launch (the admin page adds more)
  • The add-on file puts the server on the internal-only network pabal-voice-link (10.231.53.0/29 — server .2, gateway .3), and the server listens only on its own address there (with 0.0.0.0 it would also open on the shared network it is on). If that range overlaps your LAN, VPN or another Docker network, change VOICE_LINK_SUBNET, VOICE_LINK_SERVER_IP and VOICE_LINK_GATEWAY_IP together.
  • Binding to a non-loopback address requires TELEGRAM_VOICE_LINK_ANY_ADDRESS=true, and the link never binds to a public address. The server log tells you which network the link sees.
  • Unauthenticated connections get 3 seconds for the upgrade and 3 seconds for hello, and at most 2 are accepted per address and 16 in total.
  • All the server's voice settings are in Administration and settings — Voice-bot environment variables.

The gateway (pabal-voice)

cd /opt/pabal
docker build -t pabal-voice:latest voice/                                       # for this computer's CPU
docker buildx build --platform linux/amd64 -t pabal-voice:latest --load voice/  # for an Intel server
cd deploy && docker compose up -d                                         # as .env's COMPOSE_FILE and COMPOSE_PROFILES say

The image runs Python 3.12 pinned by hash, with no compiler or pip, as uid 10001. ntgcalls exists only for Linux x86_64/aarch64 and macOS arm64, so on an Intel Mac it runs only in Docker. The add-on file's pabal-voice uses this image without building it (image: pabal-voice:latest). To build it for the server's CPU on another computer and send it over, use deploy/ship.sh <user@host> <folder> --voice (the image it replaces stays as pabal-voice:previous). Below is that file's gateway service, shortened.

  pabal-voice:                             # from deploy/docker-compose.voice.yml (shortened)
    image: pabal-voice:latest
    profiles: [voice]
    init: true
    stop_grace_period: 40s                 # longer than VOICE_DRAIN_SECONDS (20)
    security_opt: ["no-new-privileges:true"]
    cap_drop: [ALL]
    mem_limit: ${VOICE_MEMORY:-1g}
    ulimits:
      core: 0
    environment:
      VOICE_LINK_URL: ws://${VOICE_LINK_SERVER_IP:-10.231.53.2}:8095/voice/link
      VOICE_LINK_SECRET_FILE: /run/secrets/voice-link.secret
      VOICE_ENGINE: gemini
      VOICE_VERTEX_KEY_FILE: /run/secrets/vertex-sa.json
      VOICE_VERTEX_REGION: us-central1
      VOICE_TURN_HOST: ${VOICE_TURN_HOST:-}  # the same value as the server's TELEGRAM_VOICE_TURN_HOST
      VOICE_CAPACITY: ${VOICE_CAPACITY:-2}
      VOICE_DAILY_MODEL_MINUTES: ${VOICE_DAILY_MODEL_MINUTES:-60}
      VOICE_SAFETY_THRESHOLD: ${VOICE_SAFETY_THRESHOLD:-MEDIUM}   # tester calls: an assumption for the operator to confirm (below)
      VOICE_BYOK: "false"
      VOICE_STREAM: "false"
    volumes:
      - ${PABAL_HOME:-/srv/pabal}/pabal_voice/secrets:/run/secrets:ro   # the link secret and the Vertex key — not in backup.sh
      - pabal_voice_state:/var/lib/pabal-voice
    networks:
      pabal-voice-link:                    # the link network shared only with the server (internal)
        ipv4_address: ${VOICE_LINK_GATEWAY_IP:-10.231.53.3}
      pabal-voice-out: {}                  # its own network out (Google, the relay)
  • Besides the link, the gateway needs a way out: to Google (us-central1-aiplatform.googleapis.com:443) and to the call relay (UDP). The link network is internal: true and cannot reach outside, so attach one more network, pabal-voice-out, as above.
  • Landlock: a gateway that uses a model or stream mode refuses to start (exit code 2) without the Linux kernel's Landlock (5.13 or later; available under Docker's default seccomp profile). This is because the media worker processes, which handle what callers send in native code, lock themselves so they cannot read the key files. Inside the container, do not mount secret files under /app·/opt·/usr·/lib·/etc (/run/secrets is fine — as in the example above). VOICE_ALLOW_NO_LANDLOCK=true is only for a development Mac. Whether Landlock works in Docker Desktop for macOS has not been checked.
  • Memory: per call, 60–100 MB for media + 30–40 MB for the engine. With capacity 8 (VOICE_CAPACITY) and one spare pair, about 1.3 GB. The add-on file's defaults are capacity 2 and 1 GB of memory (VOICE_MEMORY).
  • Safety filter: the gateway default for VOICE_SAFETY_THRESHOLD is LOW (the strictest), but in measurements it turned 13 of 172 ordinary Korean utterances into English refusals (MEDIUM: 0 of 69). The repository assumes tester calls set MEDIUM explicitly, and changing the default needs red-team testing first.
  • Limits: the gateway's defaults are 600 seconds, 400,000 tokens and 30 turns a minute per call, and 300 model minutes and 8 million tokens per day (UTC); the add-on file sets the day to 60 minutes and 1.6 million tokens (VOICE_DAILY_MODEL_*; beyond that new calls are busy). The OpenAI engine has been tested only against a fake server and is off, and there is no automatic switching between engines.
  • Languages: when a gateway connects, it tells the server which bot languages it can take calls in (from 0.7.0 on it names all nine), and the server gives a bot's calls only to a gateway that runs the bot's language — an older gateway that names none (0.6.0) gets only Korean and English bots. The notice recordings are in the image's assets/, and assets/assets.json says which of them have been reviewed. Without this file the gateway cannot load the AI notice, so it takes no calls (an ERROR in the start-up log). Recordings that have not been reviewed (today, every language other than Korean and English) are not played, and callers in those languages hear the English notices. VOICE_DRAFT_NOTICES=true is a switch for test servers only that plays the draft recordings too; unless VOICE_ALLOW_TEST_URLS=true is also set, the gateway does not start. VOICE_NOTICE_LANG (the notice language to use when the server does not say which language the caller uses; default ko) must be one of the ten recording languages (ko en ja zh fr es id vi km ar), and VOICE_LANGUAGE (the bot language for calls without the bot owner's settings; default ko-KR) must be an id from the bot language list (Voice bots — Voices and languages).
  • The link secret, keys, audio, transcripts and instructions are never written to the logs. On SIGTERM the gateway stops taking new calls, gives calls in progress VOICE_DRAIN_SECONDS (default 20 seconds), then ends them.

Check

docker compose logs pabal-server | grep -E "Voice|voice"
docker compose logs pabal-voice | head -20
INFO  TelegramServer - Voice bots on: gateways connect to ws://10.231.53.2:8095/voice/link; bots 0 listed, 2 calls each at most
INFO  VoiceGatewayLink - Voice gateway link listening on ws://10.231.53.2:8095/voice/link (network)
INFO  TelegramServer - Voice bot callers: patched apps only (desktop pabal-bc1+, Android 62151+, iOS 5+), recipients google.vertex.gemini-live@us-central1, testers 0 listed + the admin page's
INFO  VoiceGatewayLink - Voice gateway pabal-voice-1 (boot 823c9600d05e811847ce95a8f1ba9efa) connected: new session, capacity 2, features […], audio to google.vertex.gemini-live@us-central1
  • Voice bots are off: … means the link secret is missing, unreadable or not 32 bytes; — but without a TURN relay every bot call is busy at the end of the line means there is no relay.
  • If you see the ERROR Voice bots are on, but no bot is callable: the privacy policy … has no id="bot-calls" section, publish the bot-call section in the site's policy first.
  • Once a gateway connects, the server log's features shows bot_settings·end_call·context·tools, plus byok if the gateway runs owners' keys and transcripts if it has the transcript notice audio (the order varies each time). audio to must match the recipient that the apps' confirmation sheet names.
  • On the gateway side, the first line should read starting as pabal-voice-1 (capacity 2, … engine gemini, audio to google.vertex.gemini-live@us-central1), the model line session resumption off; no failover, and there should be media workers sandbox themselves with Landlock (ABI N). recordings for the callers' notices in: Korean, English lists the languages the gateway can play callers' notices in (on a test server that plays drafts too, (drafts included: …) follows).
  • Then, in the admin page's Voice (음성) tab (Administration and settings — The voice tab), check that the gateway is connected, add testers and allow bots.

Master key (own keys · stream mode)

Bot owners' model keys and stream-mode secrets are stored wrapped with this key. It must be kept somewhere that is not copied along with the backups.

sudo install -d -m 700 -o 1000 -g 1000 /etc/pabal/voice                 # outside /srv/pabal, 700
sudo sh -c 'umask 077; openssl rand -hex 32 > /etc/pabal/voice/voice-master.key'
sudo chown 1000:1000 /etc/pabal/voice/voice-master.key
      TELEGRAM_VOICE_KEYS_MASTER_FILE: /app/secrets/voice-master.key
    volumes:
      - /etc/pabal/voice:/app/secrets:ro
  • If it is inside or next to the data folder or the RSA key folder, or if its folder is open to other users at all (not 700), the server does not use it (Voice keys off: …). If the file does not exist, the server creates it when it receives the first key (with a read-only mount, create it yourself as above).
  • Do not put it in the backups; keep a separate offline copy. If it is lost, every bot owner has to set their key again. To rotate, give the old key in TELEGRAM_VOICE_KEYS_PREVIOUS_MASTER_FILE, start the server, check the log, then delete the old file.
  • Even with a master key, own keys are not accepted until you set TELEGRAM_VOICE_OWN_KEYS_APPS_READY=true after the apps show the own-key bot confirmation sheet (version 2). For stream mode, people can call only once you give the app builds with the stream confirmation sheet in TELEGRAM_VOICE_STREAM_MIN_BUILD_DESKTOP·_ANDROID·_IOS (default 0 = nobody can call), and the gateway needs VOICE_STREAM=true and this deployment's public IPs (VOICE_STREAM_DENY_ADDRESSES).

MCP components (optional)

These components call, during a call, the MCP servers that bot owners have registered. They are off by default; deploy/docker-compose.mcp.yml (profile mcp) adds three containers.

ContainerWhat it does
pabal-mcp (uid 1101)The MCP client. It connects to the server only through a socket file, and having only one internal network, it reaches outside only through the egress proxy. Read-only, no privileges, 512 MB
pabal-mcp-signer (uid 1102)The signer. It has no network at all, answers only over a socket, and writes the public issuer document
pabal-mcp-egress (uid 1103)The egress proxy. Port 443 only, and never to Pabal's own addresses
docker compose -f docker-compose.yml -f docker-compose.mcp.yml --profile mcp up -d
  • To turn it on you need TELEGRAM_MCP_EGRESS_OWN_ADDRESSES (Pabal's public addresses, required) and the key files (the link secret, K_grant, the installation key — all in 700 folders outside /srv/pabal), and the site needs the policy's bot-calls-mcp section and the terms' bot-owner section. The one-time setup is listed in order in the comments at the top of docker-compose.mcp.yml. Do not put the vault or the signing key in the backups.
  • Set TELEGRAM_MCP_APPS_READY=true once the apps show the bot's MCP recipients on the confirmation sheet. Until then, registering works but no call carries MCP tools.
  • Giving MCP requests a dedicated egress address of their own is an operating decision, not a condition in the code (because it is the exit through which callers' words go to third-party servers).
  • When it is on, the log shows MCP tools on: pabal-mcp dials …; issuer …; ports …; own addresses N; clock by NTP …. deploy/ship.sh <server> <folder> --mcp only builds and ships the three images and copies the override file; it turns nothing on (it has not been run on Linux Docker).

Apple Watch entry points (optional, coming soon)

Instead of MTProto, the Apple Watch app signs in and places calls through the server's watch API (HTTPS JSON, /api/watch/v1/), and the call audio reaches the gateway through the audio edge (pabal-voice-edge, WSS). The call relay (coturn) is not used. It is off by default.

The watch's audio edge cannot be reached from outside yet

deploy/docker-compose.voice.yml has the audio edge pabal-voice-edge (profile voice-watch, no secrets, an internal-only network), but not yet the server's watch API settings (TELEGRAM_WATCH_*) or the watch paths in the reverse proxy, so a watch outside cannot reach it. What follows describes the settings; it has not been run as a production setup (tested only on a development server).

SettingDefaultMeaning
TELEGRAM_WATCH_API_PORT—The watch API port. Without it, the watch API is off
TELEGRAM_WATCH_API_HOST, TELEGRAM_WATCH_API_ANY_ADDRESS127.0.0.1, —The address to listen on. Anything other than loopback requires ANY_ADDRESS=true, and you put a TLS proxy in front
TELEGRAM_WATCH_AUDIO_URL—The audio edge address given to the watch. The path must be exactly wss://<host>/api/watch/v1/audio. If it is missing or wrong, watch calls are off
TELEGRAM_WATCH_ALLOW_PLAIN—Allows a ws:// address. Development only (testing at home)
TELEGRAM_WATCH_SESSION_DAYS7Days a watch sign-in lasts (1–365)
TELEGRAM_WATCH_AUTH_PER_MINUTE30The server-wide limit on sign-in code requests (per minute). There is a separate per-address limit
TELEGRAM_VOICE_MIN_BUILD_WATCHOS1The first watch app build that shows the full confirmation sheet
  • Gateway: VOICE_DIRECT=true and VOICE_EDGE_DIR (the socket folder shared with the edge, by default /run/pabal-voice-edge). Watch calls work only with a 16 kHz engine (Gemini) or the echo engine.
  • Audio edge: run python -m pabal_voice.edge as a second service from the same image (VOICE_EDGE_HOST·VOICE_EDGE_PORT, by default 127.0.0.1·8096, VOICE_EDGE_DIR). The edge is the first place that unpacks what comes from the internet, so attach no secrets to it at all and put it on an internal network that only the proxy and the gateway can reach.
  • The reverse proxy passes only the two watch paths — /api/watch/v1/audio (WebSocket, to the edge) and /api/watch/v1/ (to the watch API). On the watch API side, have it append the client address at the end of X-Forwarded-For (the server counts the per-address sign-in limit from that value). On the edge side, remove X-Forwarded-For and X-Real-IP.
  • You can't sign up on the watch, and bots that use tools, MCP, an own key, stream mode or the bot owner's transcripts can't be called from it (WATCH_NOT_YET).

Keeping the conversation (optional, coming soon)

If the caller chooses it on the confirmation sheet, the call's conversation is kept as text, only in that person's chat (Using the app — Voice bots). There is no operator switch; it is on when all of the following are in place.

  • The id="bot-calls-keep-text" section in the privacy policy (draft: the repository's docs/voice-bots/p0/privacy-*.draft.html). This section is itself the switch. The apps learn whether to show the checkbox from pabal_voice_keep_text in help.getAppConfig.
  • VOICE_KEEP_TEXT on gateway 0.9.0: true by default with a model engine. With the echo engine (including stream-only gateways), true is a configuration error and the gateway doesn't start.
  • An app build with the checkbox. The checkbox wording ships in release builds only after legal review. The watch does not show wording that hasn't been reviewed; TELEGRAM_VOICE_KEEP_TEXT_UNREVIEWED=true is a switch for internal test servers only that shows the unreviewed wording.
  • On a server where it is on, voice_call ended for every bot call that isn't in stream mode is up to 10 seconds late, and 30 message numbers after the call record are reserved (so that the bot owner can't tell what the caller chose).
  • Conversation text waiting to be written after a call is only in memory, so if the server restarts in the meantime, that call's text is not kept.

Calls from bots (optional, waiting for the legal review)

A voice bot calling, first, a person who allowed that bot to call them (Voice bots — Calls from bots). It has more conditions than calls to bots, and you should not turn it on before the legal review is done. The order:

  1. The server: make the evidence-key folder (TELEGRAM_VOICE_EVIDENCE_KEY_DIR — 700, outside the backed-up folders such as the data and key folders) with new of scripts/voice-evidence-key.py (rotate to replace the key, check to check it), and set the first app build with the permission sheet as the outbound minimum build, such as TELEGRAM_VOICE_OUTBOUND_MIN_BUILD_ANDROID (environment variables). TELEGRAM_VOICE_EVIDENCE_KEY_DIR written only in .env does not reach the server — add it to pabal-server in deploy/docker-compose.voice.yml, under environment (the path inside the container) and volumes (the folder on the host).
  2. The apps: ship app builds with the permission sheet. The apps' permission-link rules know only pabal.me, so for another server its host must be added to the app builds' link rules before a link a bot sent opens the sheet (the menu and the settings list work as they are).
  3. The legal side: publish the privacy policy's id="bot-calls-outbound" section and the terms of service's id="voice-bot-owners-outbound" section (and the voice-bot-owners section, for owners to accept the terms). Drafts are in the repository's docs/voice-bots/p0/. The permission sheet's words and the outbound notice recordings (notice-out·apology-rule) are reviewed and approved at this point too.
  4. The switch: in Calls from bots on the admin page's Voice tab, set the countries and limits and turn it on with the confirmation words (Admin page — Voice tab). Each bot gets its own outbound review by the operator.
  • The gateway: only a gateway with VOICE_OUTBOUND=true places bots' calls. With a model engine it starts only when the four outbound notice recordings are approved; draft recordings (VOICE_OUTBOUND_DRAFT_NOTICES) are for test servers only.
  • Keep test- and development-only names such as TELEGRAM_VOICE_CALLME_UNREVIEWED and TELEGRAM_VOICE_TEST_DIAL out of the production .env.
  • Calls from bots are not end-to-end encrypted either — the audio goes through this server's gateway and the AI provider.

13. Map pictures in location messages (optional)

When a location is sent, the Android app and the desktop (macOS, Windows) apps do not draw the map in the bubble themselves: they ask the server for a picture of the map at that point (upload.getWebFile). The server gets it from Google's Maps Static API and passes it on. Without it, Android shows a placeholder picture and the desktop an empty box with a pin. The iPhone draws the map on the phone, so this setting does not matter there, and sending a location and the big map that opens when you tap it work either way.

  1. In the Google Cloud console, pick a project with a billing account and enable the Maps Static API.
  2. Create an API key; restrict the application to IP addresses (the public IP the server goes out to the internet from) and the API to the Maps Static API alone. The key stays on the server and never goes into the apps.
  3. Put it in .env and restart the server with docker compose up -d pabal-server.
  4. Check the log. When the maps are off, Maps in location messages are off: … says why (the key itself never appears in the log).
STATIC_MAPS_KEY=AIza…               # keep the key out of chats, logs and the repository
STATIC_MAPS_PER_DAY=300             # new pictures fetched a day (0 = no limit)
STATIC_MAPS_PER_USER_HOUR=120       # new pictures one person may cause an hour
INFO  TelegramServer - Maps in location messages from Google Static Maps (120 new per person an hour, 300 a day)
  • Cost: the Maps Static API is free up to 10,000 requests a month. The server keeps each picture for a day and hands it again to any device asking for the same map, and a picture handed again does not count toward the limits. The default limit (300 a day) comes to about 9,000 a month, inside the free tier. A request over a limit is refused with FLOOD_WAIT, and the app shows the placeholder meanwhile.
  • Privacy policy: the shared location's coordinates (latitude and longitude) go to Google — not who sent them. On the admin page → Settings → operator information, add Google LLC (Maps Static API, United States) to the processors and to the transfers abroad.
  • If the log shows Map picture not made: Google Static Maps answered 403: …, check the key's restrictions (IP, API) or the billing account.

Operations

Status and logs

cd /opt/pabal/deploy
docker compose ps
docker compose logs --since 1h pabal-server
tail -f /srv/pabal/pabal_server/logs/telegram-server.log

Updating

cd /opt/pabal && git pull
cd deploy
./backup.sh                                     # back up first
docker compose up -d --build pabal-server             # replace only the server with the new image
docker compose logs --since 5m pabal-server | grep -E "Website|ERROR"

During the few dozen seconds the server takes to restart, the app disconnects and then reconnects on its own without signing in again. Table changes are applied automatically when the server starts. Bind directories have nothing to do with the image, so docker compose down or deleting images won't delete your data — just never delete /srv/pabal.

Rolling back: going back to the previous image leaves what the new version already wrote in the database. The old version may not be able to read it (a version with the voice-bot code, for example, writes new events such as call records in chats even with voice off), so when you roll back, restore the database from the backup taken right before the update as well (Restoring).

Backups

./backup.sh            # → /srv/pabal/backups/<date-time>/{pabal.dump, server-keys-data.tar.gz}
crontab -e             # every day at 03:00:
# 0 3 * * * /opt/pabal/deploy/backup.sh >> /srv/pabal/backups/backup.log 2>&1
  • Backups older than 14 days are deleted automatically (you can change this, e.g. KEEP_DAYS=30 ./backup.sh).
  • Copy them somewhere else too. A backup on the same disk won't survive a disk failure.
  • Backups contain the RSA private key and the SMS and SMTP secrets. Guard wherever you keep them as carefully as the server itself.

Restoring

cd /opt/pabal/deploy
B=/srv/pabal/backups/20260920-030000            # the backup to restore
docker compose stop pabal-server pabal-caddy
docker compose exec -T pabal-postgres dropdb -U pabal pabal
docker compose exec -T pabal-postgres createdb -U pabal pabal
docker compose exec -T pabal-postgres pg_restore -U pabal -d pabal --no-owner < $B/pabal.dump
docker compose exec -T pabal-postgres psql -U pabal -d pabal -c "ALTER DATABASE pabal SET search_path = public"   # the database's default path — a restore does not bring it back
sudo tar -C /srv/pabal/pabal_server -xzf $B/server-keys-data.tar.gz
sudo chown -R 1000:1000 /srv/pabal/pabal_server
docker compose up -d

Moving to a new server works the same way: do steps 1–5, then instead of step 6 do the restore above and start. If you restore the same key, you don't need to rebuild the app (rebuild it if the server IP changed).

Starting and stopping

docker compose restart pabal-server       # restart only the server
docker compose stop                 # stop everything (data stays as it is)
docker compose up -d                # start again

Even if you reboot the server, the containers start again on their own, because they're set to restart: unless-stopped.

Security checklist

  • .env has permissions 600, and POSTGRES_PASSWORD is a random value
  • /srv/pabal/pabal_server/keys has permissions 700, and the private key exists nowhere except in backups
  • (If you turned on push) firebase-service-account.json has permissions 600, and its contents are not in a repository or in .env
  • (If you turned on iPhone push) AuthKey_<key ID>.p8 has permissions 600, and its contents are not in a repository or in .env
  • Firewall: only 22 (operator IPs only, if possible), 80, 443 and 8443
  • https://your-domain/admin/ returns 404 from outside
  • Admin page: test numbers (테스트 번호) off, code delivery method (코드 전달 방식) set to SMS or email, a successful Send test (테스트 발송)
  • TELEGRAM_WEBHOOK_ALLOW_LOCAL is not turned on (so bot webhooks can't reach the internal network)
  • (If you turned on voice bots) the link secret, the Vertex key and the master key are readable only by the user that reads each of them (400/600), and the master key is outside /srv/pabal so it is not in the backups. The link port is not published, and the link network is used only by the server and the gateway. The production server has no TELEGRAM_VOICE_NOTICE_CHECK or TELEGRAM_VOICE_TESTERS=*
  • SSH: password login disabled, keys only
  • Backup cron and off-site copies checked, and a restore rehearsed once
  • Automatic security updates for the server OS (unattended-upgrades)

Troubleshooting

SymptomCause → fix
The app never gets past "Connecting…"① Port 8443 is blocked → nc -vz IP 8443, check ufw and the cloud security group ② The app was built with a different public key → redo step 9 with this server's key ③ PUBLIC_IP is wrong → fix .env and run docker compose up -d pabal-server
It connects at first but soon dropsThe app moves to the address the server told it (PUBLIC_IP:MTPROTO_PORT), and that address is wrong → check PUBLIC_IP
HTTPS certificate errorDNS doesn't point to this server yet, or port 80 is blocked → dig +short your-domain, docker compose logs pabal-caddy
server keeps restarting, password authentication failedPOSTGRES_PASSWORD was changed after the data was created → change it back to the original value, or run ALTER USER pabal PASSWORD '…' inside the DB
AccessDeniedException: /app/keys/…Ownership of the bind directory → sudo chown -R 1000:1000 /srv/pabal/pabal_server
OutOfMemoryError, slownessRaise -Xmx in JAVA_OPTS and run docker compose up -d pabal-server
Sign-up codes don't arriveThe delivery status and failure reason in the admin page's Sign-up codes (가입 코드) tab → the SMS and SMTP values in Settings (설정), Send test (테스트 발송)
Android doesn't get notifications① The log says Push notifications are off → the FCM_CREDENTIALS path and the file permissions ② No Push device registered → the app was built without the Firebase configuration (pabal-placeholder), or the new app hasn't been opened yet ③ It says sent but nothing appears → the phone's notification permission and battery settings, and whether the app was "force stopped" ④ SENDER_ID_MISMATCH … Registrations are kept → the server's key and the app's google-services.json come from different projects ⑤ answered 404 without naming the token → FCM_PROJECT_ID is wrong ⑥ refused an access token → the key was revoked or belongs to another project (the server doesn't ask again for 5 minutes)
iPhone doesn't get notifications① The log says iPhone push notifications are off → the APNS_KEY path, the file permissions and the file name (AuthKey_<key ID>.p8) ② No type 1 registration → notifications weren't allowed in the app, or it's the Simulator ③ It's registered but nothing goes out → that build is lower than APNS_MIN_BUILD (the build on the registration line), or the chat is muted ④ APNs refused the provider token → the key was revoked, or APNS_TEAM_ID is not the key's team (the server doesn't send again for 5 minutes) ⑤ DeviceTokenNotForTopic … Registrations are kept → APNS_TOPIC is not the app's bundle ID ⑥ It says sent but only "You have a new notification" appears → the Notification Service Extension couldn't decrypt it (if you reinstalled the app, open it once so it registers again) ⑦ Calls only come as notifications → APNS_VOIP_MIN_BUILD is empty, or the build is lower than it (intended behavior)
Can't get into the admin pageCheck that the SSH tunnel is running, and whether another program is using port 8080 on your computer (switch to -L 18080:127.0.0.1:8080 and use localhost:18080)
network ssemiya-net declared as external, but could not be foundThe network doesn’t exist yet → docker network create ssemiya-net (or give another name in PABAL_NETWORK in .env)

Known limitations

  • It's a single-server setup. At startup it loads every account, conversation and message into memory. Horizontal scaling across several machines isn't possible yet, and as data grows you have to add memory.
  • Pending sign-up codes, bots' unfetched updates and kept-conversation text waiting to be written live in memory, so they're lost when the server restarts.
  • Sign-up code requests have an interval and a daily limit, but there's no rate limit on other requests yet. Right after going public, check the logs and dashboard often.
  • Push notifications reach the phone apps only — Android (step 10) and iPhone (step 11). On iPhone they go only to builds at or above APNS_MIN_BUILD (Pabal 1.0 (3) is left out), and the CallKit screen for calls turns on only once you set APNS_VOIP_MIN_BUILD. The macOS and Windows apps receive messages while they're running.
  • Per-chat muting is kept only in the server's memory. Push for muted chats goes to Android at non-urgent priority (the phone filters those notifications out itself) and isn't sent to iPhone at all, but after the server restarts, both go out as usual (the app tells the server again only when you change the setting). If an app that was asleep takes more than 20 seconds to wake up and reach the server, the call can end on the caller's side as unanswered.
  • Messages can carry photos and files; video and voice messages that play inside the app aren't supported yet. Channels, supergroups, group calls and two-step verification aren't available yet either.
  • Auth keys are stored in plain text in the DB (the server needs them to decrypt). Guard the DB and backups as carefully as the RSA key.

Files

FileRole
deploy/docker-compose.ymlProduction setup (pabal-server · pabal-postgres · pabal-caddy, bind mounts)
deploy/CaddyfileHTTPS, blocking management paths, forwarding the site, docs and Bot API
deploy/.env.exampleSample settings → deploy/.env
deploy/backup.shDB dump + archiving keys and data, cleaning up old backups
DockerfileServer image (build → JRE 21, uid 1000)
voice/The voice gateway pabal-voice (source, Dockerfile, README.md, the link protocol PROTOCOL.md)
deploy/docker-compose.mcp.ymlThe three MCP containers for voice bots (profile mcp)
deploy/docker-compose.voice.ymlAdds the voice-bot components — the server's voice settings and the link network, the gateway pabal-voice (profile voice) and the watch's audio edge pabal-voice-edge (profile voice-watch)
deploy/ship.shBuilds the server image (with --voice the gateway image too) for the server's CPU on another computer, loads it over SSH and copies the deployment files
deploy/voice_readiness.pyChecks before turning voice on (Vertex key, legal sections, app builds, relay, …) — prints no secret
deploy/turnserver.conf.example, deploy/check_relay.pyA sample coturn configuration for installing directly on a Mac server, and a tool for checking the relay