개발자 문서
한국어

서버 운영

서버 설치

파발 서버를 인터넷에 공개된 서버 한 대에 도커 컴포즈로 설치하고 운영하는 방법.

파발 서버를 인터넷에 공개된 서버 한 대에 설치하는 방법입니다. 도커 컴포즈로 파발 서버 · PostgreSQL · Caddy(HTTPS) 세 컨테이너를 올리고, 지워지면 안 되는 데이터는 모두 호스트 디렉터리에 바인드합니다. 위에서부터 순서대로 따라 하면 됩니다.

이 문서의 예시 값

도메인 pabal.me, 서버 공인 IP 203.0.113.10(설명용 주소 — 실제 IP 로 바꿔 읽으세요), 소스 위치 /opt/pabal, 데이터 위치 /srv/pabal, 운영체제 Ubuntu 24.04 LTS. 필요한 파일은 저장소의 deploy/ 폴더에 있습니다.

한눈에 보기

인터넷 docker compose · ssemiya-net /srv/pabal (바인드 마운트) 파발 앱Pabal.app 브라우저 · 봇사이트 · Bot API 운영자SSH pabal-server MTProto :8443 웹 · Bot API · 관리 :8080 uid 1000 · JRE 21 웹훅 → 봇 서버 (HTTPS) pabal-caddy:80 · :443 인증서 자동 pabal-postgres 16내부망 전용 · 포트 없음 pabal_server/keys · RSA 키 pabal_server/data · 사진·설정 pabal_server/logs postgres_data/ · 모든 대화 caddy/ · 인증서 backups/ · backup.sh MTProto :8443 (자체 암호화) HTTPS :443 SSH 터널 → 127.0.0.1:8080/admin/
서버 한 대, 컨테이너 셋, 남아야 하는 것은 모두 /srv/pabal 에

다이어그램 설명

  • 세 영역: 인터넷의 사용자 → 운영 서버의 컨테이너 세 개 → 호스트 디스크의 바인드 디렉터리. 컨테이너는 언제든 지우고 다시 만들 수 있고, 남아야 하는 것은 전부 /srv/pabal 있습니다.
  • 입구는 둘뿐입니다: 앱은 MTProto 포트로 서버에 바로 붙고(자체 암호화라 HTTPS 불필요), 웹사이트·문서·Bot API 는 Caddy 의 443 을 거칩니다. Caddy 는 관리용 경로(/admin, /health …)를 막고 나머지만 넘깁니다.
  • 빨간 점선은 운영자만의 길입니다. 관리 화면은 서버의 127.0.0.1:8080 에만 열려 있어 SSH 터널로만 들어갑니다. PostgreSQL 은 바깥으로 여는 포트가 아예 없습니다.
  • 빨간 칸 두 개가 가장 중요한 데이터입니다. pabal_server/keys 의 RSA 키는 앱이 몸속에 넣고 빌드되므로 잃으면 모든 앱을 다시 배포해야 하고, postgres_data/ 에는 모든 계정과 대화가 있습니다. 백업 1순위입니다.
  • 회색 화살표는 저장입니다: 서버는 키·사진·설정·로그를, PostgreSQL 은 이벤트를, Caddy 는 인증서를 각자의 디렉터리에 씁니다.

준비물

항목내용
서버Ubuntu 24.04 LTS, 시작 기준 2 vCPU · 4 GB RAM · 40 GB SSD. 사용자·메시지가 늘면 메모리부터 늘리세요
공인 IPv4고정 IP. 앱이 서버 주소를 IP 로 가지고 빌드되고, 서버도 앱에 IP 로 알려 줍니다
도메인DNS 를 바꿀 수 있는 도메인 하나 (A 레코드)
가입 코드 전달 수단SMS(Twilio · Solapi · 웹훅) 또는 이메일(SMTP) 계정 하나
앱(Pabal.app)을 이 서버용으로 빌드할 맥

포트

포트누가열기설명
22/tcp운영자운영자 IP 만 권장SSH
80/tcpCaddy모두인증서 발급 · HTTPS 로 옮기기
443/tcp, 443/udpCaddy모두웹사이트 · 문서 · Bot API (udp 는 HTTP/3)
8443/tcp서버모두앱 접속 (MTProto)
8080/tcp서버열지 않음관리 화면 · 헬스 체크 — 서버의 127.0.0.1 에만
5432/tcpPostgreSQL열지 않음컨테이너 내부망에만
왜 앱 포트가 443 이 아닌가

443 은 웹사이트(HTTPS)가 씁니다. MTProto 는 HTTP 도 TLS 도 아니어서 한 포트로 나눠 받을 수 없습니다. 흔치 않은 포트를 막는 회사·학교 망 때문에 앱도 443 으로 받고 싶다면 IP 를 하나 더 받아 그 IP 의 443 을 서버에 주세요(.env 에서 PUBLIC_IP 를 두 번째 IP, MTPROTO_PORT=443, 컴포즈 파일의 포트에 IP 를 붙여 나눔). 서버는 앱에 "이 IP 의 이 포트로 붙으라"고 알려 주므로 컨테이너 안팎의 앱 포트를 같게 둡니다.

1. 서버 준비

sudo apt update && sudo apt -y upgrade
timedatectl                      # "System clock synchronized: yes" 인지 확인

시간 동기화는 필수입니다. MTProto 메시지 번호가 시각에서 나오므로 서버 시계가 틀어지면 앱이 연결을 되풀이합니다. nosudo timedatectl set-ntp true.

# 방화벽
sudo ufw allow OpenSSH           # 가능하면: sudo ufw allow from <운영자 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

# 도커 (공식 설치 스크립트)
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER     # 다시 로그인하면 sudo 없이 docker
docker compose version            # v2 이상
도커와 ufw

도커가 여는(ports:) 포트는 ufw 규칙과 상관없이 열립니다. 그래서 컴포즈 파일은 공개할 포트(80·443·8443)만 열고, 관리 포트는 127.0.0.1:8080 으로만 열며, PostgreSQL 은 열지 않습니다. ports: 를 고칠 때 기억하세요.

2. 소스 가져오기

sudo mkdir -p /opt/pabal && sudo chown $USER: /opt/pabal
git clone <저장소 주소> /opt/pabal

개발 컴퓨터에서 복사한다면 키·데이터·로그는 보내지 마세요(rsync --exclude 'keys/' --exclude 'data/' --exclude 'logs/' --exclude '**/target/'). 운영 서버의 RSA 키는 운영 서버가 처음 켜질 때 새로 만듭니다 — 개발용 키를 운영에 쓰면 그 개발 컴퓨터가 운영 트래픽을 풀 수 있는 곳이 됩니다.

3. 데이터 디렉터리 (바인드 마운트)

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        # 서버 컨테이너는 uid 1000 으로 돕니다
sudo chown -R $USER: /srv/pabal/backups
sudo chmod 700 /srv/pabal/pabal_server/keys /srv/pabal/backups
디렉터리컨테이너 안들어 있는 것잃으면
pabal_server/keys/app/keysprivate.pem(서버 RSA 비밀키), private.pem.pub모든 앱을 다시 빌드·배포
pabal_server/data/app/data사진(media/), admin-token, operations.json(가입 코드·SMS·SMTP 설정, 비밀값 포함)사진·운영 설정
pabal_server/logs/app/logs서버 로그 (날마다 압축)기록만
postgres_data/var/lib/postgresql/data계정·대화·메시지·로그인·봇·웹훅 설정 전부서비스 전체
caddy/data, caddy/config/data, /configHTTPS 인증서다시 발급됨
backups(호스트 전용)backup.sh 결과

4. 설정 파일 .env

cd /opt/pabal/deploy
cp .env.example .env
chmod 600 .env
openssl rand -base64 30 | tr -d '/+=' | cut -c1-32     # 나온 값을 POSTGRES_PASSWORD 에
nano .env
DOMAIN=pabal.me
PUBLIC_IP=203.0.113.10
POSTGRES_PASSWORD=(위에서 만든 값)
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"
넣을 것비고
DOMAIN웹사이트 도메인앱이 만드는 링크(pabal.me/사용자명), 문서의 주소, 링크 미리보기도 이 주소
PUBLIC_IP서버의 공인 IPv4클라우드라면 콘솔에 보이는 공인 IP (서버 안의 사설 IP 가 아님)
POSTGRES_PASSWORD무작위 값처음 켜기 전에 정하세요. PostgreSQL 은 데이터 디렉터리를 처음 만들 때만 이 값을 씁니다
PABAL_HOME/srv/pabal3단계의 디렉터리
PABAL_SOURCE..서버 이미지를 빌드할 저장소. 컴포즈 파일이 저장소의 deploy/ 안에 있으면 .., 밖에 두면 그 소스 경로
PABAL_NETWORKssemiya-net세 컨테이너가 함께 붙는 도커 네트워크. 미리 만들어 둡니다(6단계)
MTPROTO_PORT8443바꾸면 방화벽과 앱 빌드도 같이
ADMIN_TOKEN비워 둠비우면 처음 켤 때 pabal_server/data/admin-token 에 만들어짐. 직접 정하려면 16자 이상
JAVA_OPTS기본값메모리가 모자라면 -Xmx 를 올리세요 (서버 메모리의 절반 정도까지)

운영용 컴포즈 파일은 테스트 번호를 끈 채(TELEGRAM_TEST_NUMBERS=false) 서버를 켭니다. 서버가 읽는 설정 값 전체는 관리와 설정 — 환경 변수에 있습니다.

5. DNS

이름종류
pabal.meA203.0.113.10
www.pabal.meA203.0.113.10
dig +short pabal.me        # 203.0.113.10 이 나와야 함 (반영에 몇 분~몇 시간)

DNS 가 서버를 가리키기 전에 켜도 됩니다. Caddy 가 인증서를 받을 때까지 알아서 다시 시도합니다.

6. 빌드하고 켜기

cd /opt/pabal/deploy
docker network inspect ssemiya-net >/dev/null 2>&1 || docker network create ssemiya-net   # 한 번만
docker compose up -d --build       # 처음엔 몇 분 (Maven 이 라이브러리를 받음)
docker compose ps                  # pabal-server, pabal-postgres, pabal-caddy 가 running (healthy)
docker compose logs -f pabal-server      # Ctrl+C 로 빠져나옴

처음 켤 때 로그에 이런 줄이 나옵니다.

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/)
  • 첫 줄은 WARN 이지만 정상입니다(처음 한 번만 키를 만든다는 알림). 다음부터는 Loaded RSA key … (fingerprint …) 가 나옵니다. fingerprint 는 서버마다 다릅니다.
  • 테이블은 서버가 켜질 때 스스로 만듭니다.

7. 확인

# 바깥(내 컴퓨터)에서
curl -sI https://pabal.me/ | head -1                              # HTTP/2 200
curl -s -o /dev/null -w '%{http_code}\n' https://pabal.me/docs/    # 200 — 이 문서
curl -s -o /dev/null -w '%{http_code}\n' https://pabal.me/admin/   # 404 — 관리 화면은 공개되지 않음
curl -s https://pabal.me/docs/server-key.pem | head -1             # -----BEGIN RSA PUBLIC KEY-----
nc -vz 203.0.113.10 8443                                          # succeeded — 앱 포트

# 서버에서
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. 관리 화면과 첫 설정

# 내 컴퓨터에서 (켜 둔 채로)
ssh -N -L 8080:127.0.0.1:8080 <사용자>@203.0.113.10

# 토큰은 서버에서
sudo cat /srv/pabal/pabal_server/data/admin-token

브라우저로 http://localhost:8080/admin/ 을 열고 토큰을 넣습니다. 운영 전에 꼭 할 설정(자세한 것은 관리와 설정):

  1. 코드 전달 방식을 SMS 또는 이메일로. "관리 화면" 방식은 운영자가 코드를 하나하나 알려 줘야 합니다.
  2. SMS 또는 이메일(SMTP) 값을 넣고 저장 → 테스트 발송으로 실제로 오는지 확인.
  3. 테스트 번호가 꺼져 있는지 확인. 켜면 누구나 +99966… 번호로 로그인할 수 있습니다.
  4. 필요하면 새 가입 허용을 끄고 시작해 초대할 사람만 먼저 받습니다.

9. 앱을 이 서버에 연결

앱은 서버 IP · 포트 · 공개키를 몸속에 넣고 빌드됩니다. 이 서버의 공개키로 다시 빌드해야 이 서버에 붙습니다. 맥에서:

# 1. 이 서버의 공개키 (비밀키가 아니라 공개키 — 문서 사이트에서 받아도 됩니다)
curl -s -o keys/production.pem.pub https://pabal.me/docs/server-key.pem

# 2. 앱 소스를 이 서버로 향하게 + 브랜드 적용
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

그다음 저장소의 docs/tdesktop-build-guide.md 5단계(configure)와 6단계(빌드)를 합니다. 결과 out/Debug/Pabal.app 은 다른 이름으로 복사해 두세요 — 같은 소스를 다른 서버용으로 다시 빌드하면 덮어써집니다.

앱을 다른 사람에게 나눠 주기 전에

지금 빌드는 서명·공증이 없는 디버그 빌드라 받은 사람이 처음 열 때 우클릭 → 열기를 해야 합니다. 디버그 빌드는 앱 옆 폴더(tdata/)에 데이터를 두고, 그곳에 쓸 수 없으면 진짜 텔레그램 데스크톱과 같은 데이터 폴더를 씁니다. 앱은 사용자 폴더(예: ~/Applications/Pabal/)에 두게 안내하세요. 일반 배포용 릴리스 빌드(데이터 폴더 분리, Apple 서명·공증)는 별도 작업입니다.

운영

상태·로그

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

업데이트

cd /opt/pabal && git pull
cd deploy
./backup.sh                                     # 먼저 백업
docker compose up -d --build pabal-server             # 새 이미지로 서버만 교체
docker compose logs --since 5m pabal-server | grep -E "Website|ERROR"

서버가 다시 켜지는 수십 초 동안 앱은 끊겼다가 다시 로그인하지 않고 스스로 다시 붙습니다. 테이블 변경은 서버가 켜질 때 알아서 적용합니다. 바인드 디렉터리는 이미지와 상관없으므로 docker compose down 이나 이미지 삭제로 데이터가 지워지지 않습니다 — /srv/pabal 을 지우지만 마세요.

백업

./backup.sh            # → /srv/pabal/backups/<날짜-시각>/{pabal.dump, server-keys-data.tar.gz}
crontab -e             # 매일 03:00:
# 0 3 * * * /opt/pabal/deploy/backup.sh >> /srv/pabal/backups/backup.log 2>&1
  • 14일이 지난 백업은 스스로 지웁니다(KEEP_DAYS=30 ./backup.sh 처럼 바꿀 수 있음).
  • 다른 곳에도 복사하세요. 같은 디스크의 백업은 디스크 고장을 이기지 못합니다.
  • 백업에는 RSA 비밀키와 SMS·SMTP 비밀값이 들어 있습니다. 보관 장소도 서버만큼 지키세요.

복구

cd /opt/pabal/deploy
B=/srv/pabal/backups/20260920-030000            # 되살릴 백업
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

새 서버로 옮길 때도 같습니다: 1~5단계를 한 뒤, 6단계 대신 위 복구를 하고 켭니다. 같은 키를 되살리면 앱을 다시 빌드할 필요가 없습니다(서버 IP 가 바뀌었다면 다시 빌드).

켜기·끄기

docker compose restart pabal-server       # 서버만 다시 켜기
docker compose stop                 # 모두 멈춤 (데이터 그대로)
docker compose up -d                # 다시 켬

서버를 재부팅해도 컨테이너는 restart: unless-stopped 라 스스로 다시 켜집니다.

보안 점검표

  • .env 권한 600, POSTGRES_PASSWORD 는 무작위 값
  • /srv/pabal/pabal_server/keys 권한 700, 비밀키가 백업 외 다른 곳에 없음
  • 방화벽: 22(가능하면 운영자 IP 만)·80·443·8443 만
  • 바깥에서 https://도메인/admin/ 이 404
  • 관리 화면: 테스트 번호 꺼짐, 코드 전달 방식 SMS/이메일, 테스트 발송 성공
  • TELEGRAM_WEBHOOK_ALLOW_LOCAL 을 켜지 않음 (봇 웹훅이 내부망으로 가지 못하게)
  • SSH: 비밀번호 로그인 끄기, 키로만
  • 백업 cron 과 외부 복사 확인, 복구를 한 번 연습
  • 서버 OS 자동 보안 업데이트(unattended-upgrades)

문제 해결

증상원인 → 해결
앱이 "연결 중…"에서 넘어가지 않음① 8443 이 막힘 → nc -vz IP 8443, ufw·클라우드 보안 그룹 확인 ② 앱을 다른 공개키로 빌드함 → 9단계를 이 서버의 키로 다시 ③ PUBLIC_IP 가 틀림 → .env 고치고 docker compose up -d pabal-server
처음엔 붙는데 곧 끊김앱이 서버가 알려 준 주소(PUBLIC_IP:MTPROTO_PORT)로 옮겨 가는데 그 주소가 틀림 → PUBLIC_IP 확인
HTTPS 인증서 오류DNS 가 아직 이 서버를 가리키지 않거나 80 포트가 막힘 → dig +short 도메인, docker compose logs pabal-caddy
server 가 계속 다시 켜짐, password authentication failedPOSTGRES_PASSWORD 를 데이터를 만든 뒤에 바꿈 → 원래 값으로 되돌리거나 DB 안에서 ALTER USER pabal PASSWORD '…'
AccessDeniedException: /app/keys/…바인드 디렉터리 소유자 → sudo chown -R 1000:1000 /srv/pabal/pabal_server
OutOfMemoryError, 느려짐JAVA_OPTS-Xmx 를 올리고 docker compose up -d pabal-server
가입 코드가 안 옴관리 화면 가입 코드 탭의 발송 상태·실패 이유 → 설정의 SMS·SMTP 값, 테스트 발송
관리 화면에 안 들어가짐SSH 터널이 켜져 있는지, 내 컴퓨터의 8080 을 다른 프로그램이 쓰는지(-L 18080:127.0.0.1:8080 으로 바꿔 localhost:18080)
network ssemiya-net declared as external, but could not be found네트워크를 아직 만들지 않았습니다 → docker network create ssemiya-net (다른 이름이면 .envPABAL_NETWORK)

알려진 한계

  • 서버 한 대 구성입니다. 켜질 때 모든 계정·대화·메시지를 메모리에 올려 둡니다. 여러 대로 나누는 수평 확장은 아직 안 되고, 데이터가 늘면 메모리를 늘려야 합니다.
  • 기다리는 가입 코드, 봇의 가져가지 않은 업데이트는 메모리에 있어 서버를 다시 켜면 사라집니다.
  • 가입 코드 요청에는 간격·하루 한도가 있지만, 그 밖의 요청 전체에 대한 속도 제한은 아직 없습니다. 공개 직후에는 로그와 대시보드를 자주 보세요.
  • 휴대폰 앱과 푸시 알림이 없습니다. 메시지 첨부는 사진만 됩니다. 채널·슈퍼그룹, 2단계 인증은 아직입니다.
  • 인증 키가 DB 에 평문으로 있습니다(서버가 복호화하려면 필요). DB 와 백업을 RSA 키만큼 지키세요.

파일

파일역할
deploy/docker-compose.yml운영 구성 (pabal-server · pabal-postgres · pabal-caddy, 바인드 마운트)
deploy/CaddyfileHTTPS, 관리 경로 차단, 사이트·문서·Bot API 전달
deploy/.env.example설정 견본 → deploy/.env
deploy/backup.shDB 덤프 + 키·데이터 보관, 오래된 백업 정리
Dockerfile서버 이미지 (빌드 → JRE 21, uid 1000)