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 app (Pabal.app) for this server |
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 becomes a place that 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 | Rebuild and redistribute every app |
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 records |
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 | They're issued again |
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"
| 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) |
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 (it's a notice that the key is created, only once). 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 · 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.
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.
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
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- 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)- 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 (테스트 발송) |
| 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 and bots' unfetched updates 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.
- There are no mobile apps or push notifications. Only photos can be attached to messages. Channels, supergroups and two-step verification aren't available yet.
- 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) |