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.
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
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/keysis 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
| Item | Details |
|---|---|
| Server | Ubuntu 24.04 LTS, starting with 2 vCPUs · 4 GB RAM · 40 GB SSD. As users and messages grow, add memory first |
| Public IPv4 | A static IP. The app is built with the server's address as an IP, and the server also tells the app its IP |
| Domain | One domain whose DNS you can change (an A record) |
| A way to deliver sign-up codes | An SMS (Twilio · Solapi · webhook) or email (SMTP) account |
| Mac | A 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
| Port | Who | Open to | Description |
|---|---|---|---|
| 22/tcp | Operator | Operator IPs only, recommended | SSH |
| 80/tcp | Caddy | Everyone | Certificate issuance · redirect to HTTPS |
| 443/tcp, 443/udp | Caddy | Everyone | Website · docs · Bot API (udp is HTTP/3) |
| 8443/tcp | Server | Everyone | App connections (MTProto) |
| 8080/tcp | Server | Keep closed | Admin page · health check — only on the server's 127.0.0.1 |
| 5432/tcp | PostgreSQL | Keep closed | Only on the containers' internal network |
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
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
| Directory | In the container | What's in it | If you lose it |
|---|---|---|---|
pabal_server/keys | /app/keys | private.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/data | Photos (media/), admin-token, operations.json (sign-up code, SMS and SMTP settings, including secrets) | Photos and operational settings |
pabal_server/logs | /app/logs | Server logs (compressed daily) | Only the logs |
postgres_data | /var/lib/postgresql/data | Everything: accounts, conversations, messages, sign-ins, bots, webhook settings | The whole service |
caddy/data, caddy/config | /data, /config | HTTPS certificates | Reissued 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
| Value | What to put | Notes |
|---|---|---|
DOMAIN | The website's domain | Links the app creates (pabal.me/username), addresses in the docs and link previews all use this address |
PUBLIC_IP | The server's public IPv4 | In the cloud, the public IP shown in the console (not the private IP inside the server) |
POSTGRES_PASSWORD | A random value | Set it before the first start. PostgreSQL only uses this value when it first creates the data directory |
PABAL_HOME | /srv/pabal | The 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_NETWORK | ssemiya-net | The Docker network the three containers join. Create it beforehand (step 6) |
MTPROTO_PORT | 8443 | If you change it, change the firewall and the app build too |
ADMIN_TOKEN | Leave empty | If empty, it's created in pabal_server/data/admin-token on first start. To set it yourself, use 16 or more characters |
JAVA_OPTS | The default | If memory runs short, raise -Xmx (up to about half the server's memory) |
FCM_CREDENTIALS | Leave empty | Android push notifications. To turn them on, set /app/keys/firebase-service-account.json (step 10). If empty, the server runs without push |
FCM_PROJECT_ID | Leave empty | Only when sending to a Firebase project other than the key file's project_id |
PUSH_PREVIEWS | true | With 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
| Name | Type | Value |
|---|---|---|
pabal.me | A | 203.0.113.10 |
www.pabal.me | A | 203.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):
- Set the code delivery method (코드 전달 방식) to SMS or email. With the "Admin page" (관리 화면) method, the operator has to pass on every code by hand.
- Enter the SMS or email (SMTP) (이메일 (SMTP)) values and save → use Send test (테스트 발송) to check that messages really arrive.
- Check that test numbers (테스트 번호) are off. If they're on, anyone can sign in with a
+99966…number. - 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.
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.
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.jsongoes 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'skeys/. 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.
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
- In the Firebase console, choose Add project. Any name will do, and you don't need Google Analytics.
- 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. - Download that app's
google-services.json. It's configuration that goes into the app, not a secret. - 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. - 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 withmessage 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.comandfcm.googleapis.com(443).
Check
- 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. - 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.
- Send it a message from another account: the log shows
Push MESSAGE_TEXT to user …: sentand 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 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_BUILDonly from builds made to do that; before then, calls go as notifications too.
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
- Apple Developer → Certificates, IDs & Profiles → Keys → +. It must be the account of the team that signed the app.
- 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.
- Register → Download.
AuthKey_<key ID>.p8can 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=
| Value | What to put | Notes |
|---|---|---|
APNS_KEY | The key file's path | The 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_ID | The ID of the team that signed the app | For Pabal's app, 4Z8R468M2D. For an app you built and signed yourself, that team's ID |
APNS_TOPIC | The app's bundle ID | For Pabal's app, me.pabal.ios. Calls (VoIP) go to this with .voip added to the end |
APNS_MIN_BUILD | 4 | Nothing 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_BUILD | Leave empty | If 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 toapi.sandbox.push.apple.com(the app says so when it registers).
Check
- 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 atype 9line. You can't check this with the Simulator — it has neither APNs tokens nor CallKit. - Swipe the app up to close it.
- 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.
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.
| Component | What it does | Without 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 key | A Google Cloud service account JSON key. Only the gateway's engine worker process opens it | Echo only, with no model (VOICE_ENGINE=echo; the audio never leaves the machine) |
| (Optional) Master key | The key that wraps bot owners' own keys and stream-mode secrets | Own keys and stream mode off |
| (Optional) MCP components | pabal-mcp · the signer · the egress proxy (below) | MCP servers off |
Conditions for turning on
| What the server looks for | Without 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 here | No 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 service | Nobody 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" sections | Stream 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-turnfrom the compose file (COMPOSE_PROFILES=turn). SetTURN_SECRETin.env(openssl rand -hex 32), and openTURN_PORT(3478, udp and tcp) andTURN_MIN_PORT–TURN_MAX_PORT(49160–49200, udp) on the router and firewall. If you widen the range, change the router and.envtogether. - 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_HOSTand the gateway'sVOICE_TURN_HOST.host.docker.internaldid not reach it, and with127.0.0.1allocations did not complete. The apps still get the public address (TURN_HOST). - From outside your network, check with
deploy/check_relay.py --datathat audio actually crosses the relay. Sometimes STUN answers and allocations succeed, yet the audio does not get through.
Vertex AI key
- 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. - Create a service account and give it only one role,
roles/aiplatform.user(to narrow it further, a custom role with just theaiplatform.endpoints.predictpermission). Create the production key separately from the development one. - 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. - 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). - 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 link and server settings
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 (with0.0.0.0it would also open on the shared network it is on). If that range overlaps your LAN, VPN or another Docker network, changeVOICE_LINK_SUBNET,VOICE_LINK_SERVER_IPandVOICE_LINK_GATEWAY_IPtogether. - 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 isinternal: trueand 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/secretsis fine — as in the example above).VOICE_ALLOW_NO_LANDLOCK=trueis 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_THRESHOLDisLOW(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 setMEDIUMexplicitly, 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/, andassets/assets.jsonsays 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=trueis a switch for test servers only that plays the draft recordings too; unlessVOICE_ALLOW_TEST_URLS=trueis 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; defaultko) must be one of the ten recording languages (koenjazhfresidvikmar), andVOICE_LANGUAGE(the bot language for calls without the bot owner's settings; defaultko-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 busyat 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
featuresshowsbot_settings·end_call·context·tools, plusbyokif the gateway runs owners' keys andtranscriptsif it has the transcript notice audio (the order varies each time).audio tomust 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 linesession resumption off; no failover, and there should bemedia workers sandbox themselves with Landlock (ABI N).recordings for the callers' notices in: Korean, Englishlists 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=trueafter 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 inTELEGRAM_VOICE_STREAM_MIN_BUILD_DESKTOP·_ANDROID·_IOS(default 0 = nobody can call), and the gateway needsVOICE_STREAM=trueand 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.
| Container | What 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'sbot-calls-mcpsection and the terms' bot-owner section. The one-time setup is listed in order in the comments at the top ofdocker-compose.mcp.yml. Do not put the vault or the signing key in the backups. - Set
TELEGRAM_MCP_APPS_READY=trueonce 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> --mcponly 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.
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).
| Setting | Default | Meaning |
|---|---|---|
TELEGRAM_WATCH_API_PORT | — | The watch API port. Without it, the watch API is off |
TELEGRAM_WATCH_API_HOST, TELEGRAM_WATCH_API_ANY_ADDRESS | 127.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_DAYS | 7 | Days a watch sign-in lasts (1–365) |
TELEGRAM_WATCH_AUTH_PER_MINUTE | 30 | The server-wide limit on sign-in code requests (per minute). There is a separate per-address limit |
TELEGRAM_VOICE_MIN_BUILD_WATCHOS | 1 | The first watch app build that shows the full confirmation sheet |
- Gateway:
VOICE_DIRECT=trueandVOICE_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.edgeas a second service from the same image (VOICE_EDGE_HOST·VOICE_EDGE_PORT, by default127.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 ofX-Forwarded-For(the server counts the per-address sign-in limit from that value). On the edge side, removeX-Forwarded-ForandX-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'sdocs/voice-bots/p0/privacy-*.draft.html). This section is itself the switch. The apps learn whether to show the checkbox frompabal_voice_keep_textinhelp.getAppConfig. VOICE_KEEP_TEXTon gateway 0.9.0:trueby default with a model engine. With the echo engine (including stream-only gateways),trueis 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=trueis a switch for internal test servers only that shows the unreviewed wording. - On a server where it is on,
voice_call endedfor 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:
- 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) withnewofscripts/voice-evidence-key.py(rotateto replace the key,checkto check it), and set the first app build with the permission sheet as the outbound minimum build, such asTELEGRAM_VOICE_OUTBOUND_MIN_BUILD_ANDROID(environment variables).TELEGRAM_VOICE_EVIDENCE_KEY_DIRwritten only in.envdoes not reach the server — add it topabal-serverindeploy/docker-compose.voice.yml, underenvironment(the path inside the container) andvolumes(the folder on the host). - 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). - The legal side: publish the privacy policy's
id="bot-calls-outbound"section and the terms of service'sid="voice-bot-owners-outbound"section (and thevoice-bot-ownerssection, for owners to accept the terms). Drafts are in the repository'sdocs/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. - 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=trueplaces 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_UNREVIEWEDandTELEGRAM_VOICE_TEST_DIALout 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.
- In the Google Cloud console, pick a project with a billing account and enable the Maps Static API.
- 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.
- Put it in
.envand restart the server withdocker compose up -d pabal-server. - 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
.envhas permissions 600, andPOSTGRES_PASSWORDis a random value/srv/pabal/pabal_server/keyshas permissions 700, and the private key exists nowhere except in backups- (If you turned on push)
firebase-service-account.jsonhas permissions 600, and its contents are not in a repository or in.env - (If you turned on iPhone push)
AuthKey_<key ID>.p8has 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_LOCALis 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/pabalso 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 noTELEGRAM_VOICE_NOTICE_CHECKorTELEGRAM_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
| Symptom | Cause → 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 drops | The app moves to the address the server told it (PUBLIC_IP:MTPROTO_PORT), and that address is wrong → check PUBLIC_IP |
| HTTPS certificate error | DNS 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 failed | POSTGRES_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, slowness | Raise -Xmx in JAVA_OPTS and run docker compose up -d pabal-server |
| Sign-up codes don't arrive | The 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 page | Check 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 found | The 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 setAPNS_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
| File | Role |
|---|---|
deploy/docker-compose.yml | Production setup (pabal-server · pabal-postgres · pabal-caddy, bind mounts) |
deploy/Caddyfile | HTTPS, blocking management paths, forwarding the site, docs and Bot API |
deploy/.env.example | Sample settings → deploy/.env |
deploy/backup.sh | DB dump + archiving keys and data, cleaning up old backups |
Dockerfile | Server 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.yml | The three MCP containers for voice bots (profile mcp) |
deploy/docker-compose.voice.yml | Adds 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.sh | Builds 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.py | Checks before turning voice on (Vertex key, legal sections, app builds, relay, …) — prints no secret |
deploy/turnserver.conf.example, deploy/check_relay.py | A sample coturn configuration for installing directly on a Mac server, and a tool for checking the relay |