서버 운영
서버 설치
파발 서버를 인터넷에 공개된 서버 한 대에 도커 컴포즈로 설치하고 운영하는 방법.
파발 서버를 인터넷에 공개된 서버 한 대에 설치하는 방법입니다. 도커 컴포즈로 파발 서버 · PostgreSQL · Caddy(HTTPS) 세 컨테이너를 올리고, 지워지면 안 되는 데이터는 모두 호스트 디렉터리에 바인드합니다. 위에서부터 순서대로 따라 하면 됩니다.
도메인 pabal.me, 서버 공인 IP 203.0.113.10(설명용 주소 — 실제 IP 로 바꿔 읽으세요), 소스 위치 /opt/pabal, 데이터 위치 /srv/pabal, 운영체제 Ubuntu 24.04 LTS. 필요한 파일은 저장소의 deploy/ 폴더에 있습니다.
한눈에 보기
다이어그램 설명
- 세 영역: 인터넷의 사용자 → 운영 서버의 컨테이너 세 개 → 호스트 디스크의 바인드 디렉터리. 컨테이너는 언제든 지우고 다시 만들 수 있고, 남아야 하는 것은 전부
/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/tcp | Caddy | 모두 | 인증서 발급 · HTTPS 로 옮기기 |
| 443/tcp, 443/udp | Caddy | 모두 | 웹사이트 · 문서 · Bot API (udp 는 HTTP/3) |
| 8443/tcp | 서버 | 모두 | 앱 접속 (MTProto) |
| 8080/tcp | 서버 | 열지 않음 | 관리 화면 · 헬스 체크 — 서버의 127.0.0.1 에만 |
| 5432/tcp | PostgreSQL | 열지 않음 | 컨테이너 내부망에만 |
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 메시지 번호가 시각에서 나오므로 서버 시계가 틀어지면 앱이 연결을 되풀이합니다. no 면 sudo 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 이상
도커가 여는(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/keys | private.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, /config | HTTPS 인증서 | 다시 발급됨 |
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/pabal | 3단계의 디렉터리 |
PABAL_SOURCE | .. | 서버 이미지를 빌드할 저장소. 컴포즈 파일이 저장소의 deploy/ 안에 있으면 .., 밖에 두면 그 소스 경로 |
PABAL_NETWORK | ssemiya-net | 세 컨테이너가 함께 붙는 도커 네트워크. 미리 만들어 둡니다(6단계) |
MTPROTO_PORT | 8443 | 바꾸면 방화벽과 앱 빌드도 같이 |
ADMIN_TOKEN | 비워 둠 | 비우면 처음 켤 때 pabal_server/data/admin-token 에 만들어짐. 직접 정하려면 16자 이상 |
JAVA_OPTS | 기본값 | 메모리가 모자라면 -Xmx 를 올리세요 (서버 메모리의 절반 정도까지) |
운영용 컴포즈 파일은 테스트 번호를 끈 채(TELEGRAM_TEST_NUMBERS=false) 서버를 켭니다. 서버가 읽는 설정 값 전체는 관리와 설정 — 환경 변수에 있습니다.
5. DNS
| 이름 | 종류 | 값 |
|---|---|---|
pabal.me | A | 203.0.113.10 |
www.pabal.me | A | 203.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/ 을 열고 토큰을 넣습니다. 운영 전에 꼭 할 설정(자세한 것은 관리와 설정):
- 코드 전달 방식을 SMS 또는 이메일로. "관리 화면" 방식은 운영자가 코드를 하나하나 알려 줘야 합니다.
- SMS 또는 이메일(SMTP) 값을 넣고 저장 → 테스트 발송으로 실제로 오는지 확인.
- 테스트 번호가 꺼져 있는지 확인. 켜면 누구나
+99966…번호로 로그인할 수 있습니다. - 필요하면 새 가입 허용을 끄고 시작해 초대할 사람만 먼저 받습니다.
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 failed | POSTGRES_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 (다른 이름이면 .env 의 PABAL_NETWORK) |
알려진 한계
- 서버 한 대 구성입니다. 켜질 때 모든 계정·대화·메시지를 메모리에 올려 둡니다. 여러 대로 나누는 수평 확장은 아직 안 되고, 데이터가 늘면 메모리를 늘려야 합니다.
- 기다리는 가입 코드, 봇의 가져가지 않은 업데이트는 메모리에 있어 서버를 다시 켜면 사라집니다.
- 가입 코드 요청에는 간격·하루 한도가 있지만, 그 밖의 요청 전체에 대한 속도 제한은 아직 없습니다. 공개 직후에는 로그와 대시보드를 자주 보세요.
- 휴대폰 앱과 푸시 알림이 없습니다. 메시지 첨부는 사진만 됩니다. 채널·슈퍼그룹, 2단계 인증은 아직입니다.
- 인증 키가 DB 에 평문으로 있습니다(서버가 복호화하려면 필요). DB 와 백업을 RSA 키만큼 지키세요.
파일
| 파일 | 역할 |
|---|---|
deploy/docker-compose.yml | 운영 구성 (pabal-server · pabal-postgres · pabal-caddy, 바인드 마운트) |
deploy/Caddyfile | HTTPS, 관리 경로 차단, 사이트·문서·Bot API 전달 |
deploy/.env.example | 설정 견본 → deploy/.env |
deploy/backup.sh | DB 덤프 + 키·데이터 보관, 오래된 백업 정리 |
Dockerfile | 서버 이미지 (빌드 → JRE 21, uid 1000) |