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 app (Pabal.app) for this server

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 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
DirectoryIn the containerWhat's in itIf you lose it
pabal_server/keys/app/keysprivate.pem (the server's RSA private key), private.pem.pubRebuild and redistribute every app
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 records
postgres_data/var/lib/postgresql/dataEverything: accounts, conversations, messages, sign-ins, bots, webhook settingsThe whole service
caddy/data, caddy/config/data, /configHTTPS certificatesThey'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"
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)

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 (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):

  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 · 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.

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

  • .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
  • 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)
  • 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 (테스트 발송)
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 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

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)
© 2026 Pabal.me · Based on the Pabal server as of 2026-09-19 Docs home · Pabal.me