[개발일지 #03 | 인프라] 유그린 NAS에 Caddy & PostgreSQL 구축하기

카카오톡 같은 크로스플랫폼 메신저 ‘딩동(DingDong)’ 만들기 프로젝트 3탄!
이번 글에서는 우리 집 메인 서버인 유그린(Ugreen) NAS(UGOS Pro)의 Docker 환경 위에, 스마트폰 보안 통신(HTTPS)과 도메인 라우팅의 대문 역할을 맡을 Caddy 웹서버와 메신저의 핵심 데이터베이스인 PostgreSQL 16을 각각 독립된 컨테이너로 분리해서 세팅합니다.


1. 왜 Caddy와 PostgreSQL을 분리해서 설치할까요?

처음 Docker를 세팅할 때는 하나의 docker-compose.yml에 웹서버와 DB를 함께 묶기도 하지만, 홈서버를 오래 운영하려면 역할에 따라 분리해 두는 편이 훨씬 편합니다.

① Caddy를 독립시키는 이유 (홈서버 전체의 통합 대문)

  • 자동 HTTPS(SSL) 전담: iOS와 Android 9 이상은 기본 설정에서 암호화되지 않은 http:// 통신을 막습니다. Caddy는 도메인만 적어 두면 Let’s Encrypt SSL 인증서를 자동으로 발급받고, 만료 전에 자동으로 갱신해 줍니다. (80/443 포트 연결과 도메인 설정이 유지되는 동안에 한해서입니다.)
  • 모든 서비스의 교통정리(Reverse Proxy): NAS 안에는 메신저 API 서버(3000), 통화 서버(7880)뿐 아니라 앞으로 추가할 서비스도 돌아갑니다. Caddy를 따로 띄워 두면 메신저 DB나 백엔드를 재시작해도 다른 서비스의 웹 접속은 끊기지 않습니다.

② 아키텍처 구조도

공유기의 80, 443 포트를 NAS로 포트포워딩하고, Caddy가 공용 Docker 네트워크(caddy-net)를 통해 각 컨테이너로 트래픽을 나눠 줍니다.

flowchart LR
    classDef nodeStyle fill:#1e293b,stroke:#475569,stroke-width:2px,color:#ffffff;
    classDef dbStyle fill:#0f172a,stroke:#0284c7,stroke-width:2px,color:#ffffff;

    A["스마트폰 앱 (HTTPS 443)"]:::nodeStyle --> B["공유기 (80, 443 포트포워딩)"]:::nodeStyle
    B --> C["유그린 NAS Caddy (자동 SSL)"]:::nodeStyle
    C -->|"api.내도메인"| D["백엔드 API (내부 3000)"]:::nodeStyle
    C -->|"rtc.내도메인"| E["LiveKit 통화 (내부 7880)"]:::nodeStyle
    D --> F[("PostgreSQL 16 (DB)")]:::dbStyle

2. 폴더 구조 및 사전 준비

💡 저장소 배치 팁

  • Caddy 설정과 PostgreSQL DB: 응답 속도가 빠르고 HDD 절전(Sleep)의 영향을 받지 않도록 NVMe SSD 볼륨(/volume1)에 둡니다.
  • 사진/동영상/파일 저장소: 나중에 백엔드와 연동할 때 대용량 미디어는 HDD 볼륨(/volume2)에 마운트합니다.
/volume1/docker/                   # SSD 볼륨 루트
 ├─ caddy/                         # [독립] NAS 전체 리버스 프록시 전담
 │   ├─ docker-compose.yml
 │   ├─ .env                       # 도메인 환경변수 관리
 │   ├─ Caddyfile                  # 라우팅 및 자동 SSL 설정
 │   ├─ data/                      # 발급된 SSL 인증서와 개인키 보관 폴더
 │   └─ config/                    # Caddy 설정 캐시 폴더
 │
 └─ dingdong/                      # [독립] 딩동 프로젝트 전용 폴더
     └─ postgres/                  # PostgreSQL 16 데이터베이스
         ├─ docker-compose.yml
         ├─ .env                   # DB 계정 및 비밀번호 (보안 주의!)
         └─ data/                  # 실제 DB 데이터 영구 저장소

필수 사전 작업

Caddy가 인터넷에서 SSL 인증서를 발급받으려면 아래 네 가지가 먼저 준비되어야 합니다.

① 도메인 연결: 사용할 도메인(예: api.내도메인, rtc.내도메인)이 우리 집 공인 IP를 가리키고 있어야 합니다. DuckDNS 같은 무료 DDNS를 쓴다면 api.내이름.duckdns.org 같은 하위 도메인도 같은 IP로 연결되는지 먼저 확인해 보세요.

② 공유기 포트포워딩: 공유기 설정 화면에서 아래 포트를 NAS의 내부 IP로 포워딩합니다.

외부 포트프로토콜용도
80TCP인증서 발급 확인, HTTP → HTTPS 리다이렉트
443TCPHTTPS 통신
443UDPHTTP/3(QUIC) 통신 (선택)

⚠️ PostgreSQL 포트(5432)는 포트포워딩하지 않습니다. DB는 인터넷에서 직접 접근할 이유가 전혀 없습니다.

③ 80/443 포트 충돌 확인: NAS에서 이미 80이나 443 포트를 쓰는 서비스가 있으면 Caddy가 뜨지 않습니다. NAS 터미널(SSH)에서 확인해 보세요.

sudo ss -tlnp | grep -E ':(80|443)\s'

아무것도 나오지 않으면 비어 있는 것입니다.

④ Docker 공용 네트워크 생성: 분리된 컨테이너들이 이름으로 통신할 수 있도록, NAS 터미널(SSH)에서 아래 명령으로 caddy-net 네트워크를 미리 만들어 둡니다.

docker network create caddy-net

3. [파트 1] Caddy 단독 설치하기 (리버스 프록시 & 자동 SSL)

먼저 NAS의 대문 역할을 할 Caddy부터 구축합니다.

3.1 Caddy 환경변수 파일 (/volume1/docker/caddy/.env)

도메인 주소를 한곳에서 관리하기 위해 .env 파일을 만듭니다.

# 도메인 설정 (예시입니다. 본인 도메인으로 변경하세요)
DOMAIN_API=api.myfamily.com
DOMAIN_RTC=rtc.myfamily.com

3.2 Caddy 설정 파일 (/volume1/docker/caddy/Caddyfile)

도메인 앞에 http://를 붙이지 않고 도메인 이름만 적으면, Caddy가 자동으로 HTTPS 인증서를 발급받아 적용합니다.

# 1. 딩동 API 서버 및 WebSocket 리버스 프록시 (자동 HTTPS 적용)
{$DOMAIN_API} {
    # 대용량 사진/파일 전송을 위해 요청 본문 크기 상한을 310MB로 설정
    request_body {
        max_size 310MB
    }

    # 압축 전송 활성화 (모바일 데이터 절약)
    encode zstd gzip

    # 백엔드 API 컨테이너로 전달 (WebSocket 업그레이드도 Caddy가 처리함)
    reverse_proxy dingdong-api:3000

    # 보안 헤더 설정
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "DENY"
        -Server
    }
}

# 2. LiveKit 통화 서버 시그널링 프록시 (자동 HTTPS 적용)
{$DOMAIN_RTC} {
    reverse_proxy host.docker.internal:7880
}

📌 LiveKit(통화)은 포트가 하나 더 필요합니다. 위 설정은 통화 연결을 맺는 시그널링(7880) 만 Caddy를 거칩니다. 실제 음성 데이터를 주고받는 WebRTC 미디어는 Caddy를 거치지 않고 별도의 TCP/UDP 포트(LiveKit 기본값 기준 TCP 7881과 UDP 50000~60000번대)를 사용하므로, 통화 편에서 해당 포트를 공유기에 추가로 포워딩해야 합니다. 정확한 포트는 LiveKit 문서와 설정 파일에서 확인하고, 지금 단계에서는 80/443만 열어 두면 됩니다.

3.3 Caddy 실행 파일 (/volume1/docker/caddy/docker-compose.yml)

호스트의 80, 443 포트를 직접 매핑하고, 앞서 만든 caddy-net 네트워크에 연결합니다.

services:
  caddy:
    image: caddy:2-alpine
    container_name: caddy
    restart: unless-stopped
    env_file: .env                      # Caddyfile의 {$DOMAIN_API} 변수를 읽기 위해 필수!
    ports:
      - "80:80"                         # 인증서 발급 확인 및 HTTP -> HTTPS 리다이렉트용
      - "443:443"                       # 실제 보안 통신(HTTPS) 포트
      - "443:443/udp"                   # HTTP/3 (QUIC) 지원용
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - ./data:/data                    # 인증서와 개인키가 저장되는 곳 (삭제 주의!)
      - ./config:/config
    extra_hosts:
      # 리눅스 Docker에서 host.docker.internal(호스트 IP)을 인식하기 위해 필요
      - "host.docker.internal:host-gateway"
    networks:
      - caddy-net

networks:
  caddy-net:
    external: true                      # 미리 만들어 둔 외부 공용 네트워크 사용

⚠️ ./data 폴더에는 인증서와 개인키가 들어 있습니다. 지우면 인증서를 다시 발급받아야 하고, 짧은 기간에 재발급을 반복하면 Let’s Encrypt의 발급 횟수 제한에 걸릴 수 있습니다. 외부에 공유하거나 올리지 말고 백업할 때도 주의하세요.

3.4 Caddy 컨테이너 실행 및 인증서 발급 확인

터미널에서 Caddy 폴더로 이동해 컨테이너를 실행합니다.

cd /volume1/docker/caddy
docker compose up -d

# 인증서가 정상적으로 발급되었는지 로그 확인 (빠져나올 땐 Ctrl + C)
docker compose logs -f

로그에 certificate obtained successfully 같은 문구가 보이면 무료 SSL 인증서 발급이 끝난 것입니다. 아직 백엔드(dingdong-api)를 띄우지 않았으므로 브라우저로 접속하면 502 Bad Gateway가 나오지만, 주소창에 보안 자물쇠(HTTPS)가 표시되면 정상입니다. 터미널에서는 아래처럼 확인할 수도 있습니다.

curl -I https://api.myfamily.com

🔧 인증서 발급이 안 될 때 체크리스트

  1. 도메인이 정말 우리 집 공인 IP를 가리키는가? (공유기 WAN IP와 비교)
  2. 공유기에서 80, 443이 NAS 내부 IP로 포워딩되어 있는가?
  3. 통신사가 80/443을 막고 있거나 공인 IP가 아닌(CGNAT) 환경은 아닌가?
  4. NAS에서 80/443을 이미 다른 서비스가 쓰고 있지는 않은가? (2장 ③번 명령)

원인은 docker compose logs의 에러 메시지에 대부분 나옵니다.


4. [파트 2] PostgreSQL 16 단독 설치하기 (데이터베이스)

이제 Caddy와 분리된 딩동 프로젝트 폴더에서 데이터베이스를 구축합니다.

4.1 PostgreSQL 환경변수 파일 (/volume1/docker/dingdong/postgres/.env)

먼저 NAS 터미널에서 무작위 비밀번호를 만듭니다. 영문과 숫자로만 된 값이라 나중에 접속 주소(DSN)에 넣을 때도 특수문자 때문에 깨지지 않습니다.

openssl rand -hex 24

출력된 값을 복사해 .env 파일에 넣습니다.

POSTGRES_DB=dingdong
POSTGRES_USER=dingdong
POSTGRES_PASSWORD=CHANGE_ME_위에서_만든_비밀번호로_교체

⚠️ 비밀번호는 다른 사람이 볼 수 있는 곳(블로그, GitHub, 캡처 화면)에 올리지 마세요. 파일 권한도 제한해 두세요.

chmod 600 .env

⚠️ POSTGRES_PASSWORD는 DB를 처음 만들 때 한 번만 적용됩니다. 이미 data/ 폴더에 DB가 만들어진 뒤에는 .env의 값만 바꿔도 비밀번호가 바뀌지 않습니다. 예전 글의 예시 비밀번호로 DB를 만들어 두었다면 아래처럼 직접 바꿔야 합니다.

docker compose exec postgres psql -U dingdong -d dingdong \
  -c "ALTER USER dingdong WITH PASSWORD '새_비밀번호';"

바꾼 뒤에는 .env와 백엔드 쪽 접속 설정도 같은 값으로 맞춰야 합니다.

4.2 PostgreSQL 실행 파일 (/volume1/docker/dingdong/postgres/docker-compose.yml)

DB는 인터넷에서 직접 접근할 필요가 없습니다. 나중에 띄울 백엔드 서버와 내부에서만 통신하도록 전용 네트워크(dingdong-net)를 사용합니다.

services:
  postgres:
    image: postgres:16-alpine
    container_name: dingdong-postgres
    restart: unless-stopped
    env_file: .env                      # POSTGRES_DB / USER / PASSWORD를 읽어 옴
    environment:
      TZ: Asia/Seoul
    ports:
      # NAS 자기 자신(127.0.0.1)에서만 접속 가능하게 묶습니다.
      # 내 Mac에서는 4.4의 SSH 터널로 접속합니다.
      - "127.0.0.1:5432:5432"
    volumes:
      - ./data:/var/lib/postgresql/data
    healthcheck:                        # DB가 준비되었는지 확인 (다음 편에서 백엔드가 이를 기다립니다)
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 10
    networks:
      - dingdong-net

networks:
  dingdong-net:
    name: dingdong-net                  # 백엔드 API 서버와 DB가 대화할 전용 내부 네트워크

💡 왜 127.0.0.1:5432:5432인가요?
"5432:5432"처럼 적으면 NAS의 모든 네트워크 인터페이스에 DB 포트가 열립니다. “공유기에서 포트포워딩만 안 하면 안전하다”고 생각하기 쉽지만, 공유기의 UPnP 자동 포트포워딩이나 설정 실수, 같은 집 네트워크의 다른 기기를 생각하면 DB를 굳이 열어 둘 이유가 없습니다. 또 Docker는 자체적으로 방화벽 규칙을 만들기 때문에 NAS의 방화벽 설정만 믿을 수도 없습니다. 그래서 NAS 자신에게만 열어 두고, 개발 PC에서는 아래 SSH 터널로 접속합니다.

4.3 PostgreSQL 컨테이너 실행 및 연결 테스트

터미널에서 PostgreSQL 폴더로 이동해 컨테이너를 실행합니다.

cd /volume1/docker/dingdong/postgres

# 1. 백그라운드 실행
docker compose up -d

# 2. 컨테이너 정상 구동 여부 확인 (STATUS가 healthy면 준비 완료)
docker compose ps

컨테이너 내부의 psql 명령어로 데이터베이스가 잘 만들어졌는지 확인합니다.

docker compose exec postgres psql -U dingdong -d dingdong -c "\l"

표 목록에 dingdong 데이터베이스가 보이면 DB 준비가 끝난 것입니다.

4.4 내 Mac에서 DB에 접속하기 (SSH 터널)

DBeaver 같은 도구로 접속하거나 다음 편들에서 스키마를 적용하려면 Mac에서 DB에 닿아야 합니다. Mac 터미널에서 아래 명령을 실행하고 창을 열어 둔 채로 사용하세요. (NAS의 SSH가 포트 포워딩을 막아 두지 않았다면 동작합니다.)

ssh -N -L 5432:127.0.0.1:5432 <NAS계정>@<NAS내부IP>

이제 Mac에서 localhost:5432로 접속하면 NAS의 DB에 연결됩니다. 이미 Mac에 PostgreSQL이 설치되어 5432 포트를 쓰고 있다면 -L 15432:127.0.0.1:5432처럼 바꾸고 localhost:15432로 접속하세요.명령어 실행 후 표 목록에 dingdong 데이터베이스가 보이면 DB 준비도 완벽히 끝났습니다!


5. 분리형 구조의 핵심 요약 (다음 편과의 연결 고리)

이렇게 Caddy와 PostgreSQL을 분리해 두면 관리가 훨씬 깔끔해집니다.

  • 독립적인 유지보수: 앞으로 NAS에 다른 웹 서비스를 추가할 때는 /volume1/docker/caddy/Caddyfile에 도메인 두세 줄을 추가하고 아래 명령으로 설정을 다시 읽게 하면, DB나 다른 컨테이너를 재시작하지 않고 반영할 수 있습니다.docker exec -w /etc/caddy caddy caddy reload(설정이 반영되지 않으면 docker compose restart caddy로 재시작해 보세요.)
  • 네트워크 분리: 다음 편에서 만들 백엔드 API 컨테이너(dingdong-api)만 caddy-net(Caddy와 통신용)과 dingdong-net(DB와 통신용) 양쪽에 연결합니다. DB는 caddy-net에 없고 포트도 NAS 자기 자신에게만 열려 있으므로, 인터넷에서 DB로 바로 들어오는 길이 없는 구조가 됩니다.

📌 이 구조는 DB를 인터넷에서 직접 닿지 않게 하는 기본 단계입니다. 안전하게 운영하려면 NAS와 Docker 이미지를 주기적으로 업데이트하고, DB 비밀번호를 지키고, 백업도 따로 준비해야 합니다. 백업은 시리즈 뒤쪽에서 다룹니다.


6. 다음 편 예고

이제 NAS의 HTTPS 통신을 맡을 Caddy 리버스 프록시와 메신저의 심장인 PostgreSQL 16 데이터베이스가 준비되었습니다.

다음 **[개발일지 #04 | 백엔드]**에서는 Go로 가벼운 API 서버를 만들어 Docker로 배포하고, Caddy를 통해 실제 도메인으로 접속해 봅니다. DB 스키마(회원, 채팅방, 메시지 테이블)를 만들고 마이그레이션하는 과정은 그다음 편(#05)에서 다룹니다!

📂 Part 1. 기획 & 아키텍처 설계

📂 Part 2. 홈서버 & 인프라 구축

📂 Part 3. 백엔드(API) & 실시간 통화 서버

📂 Part 4. 모바일 앱 개발 (iOS / Android)

  • ✅ [개발일지 #07 | 클라이언트] Flutter 앱 첫 연동: Secure Storage 토큰 관리, Dio 자동 갱신 & 로그인 화면 구현
  • ⏳ [#09 | 클라이언트] 친구 목록 동기화 & 채팅방 목록 UI 연동하기 (예정)
  • ⏳ [#11 | 클라이언트] 실시간 WebSocket 채팅방 화면 & 끊김 없는 대화 동기화 구현 (예정)
  • ⏳ [#12 | 앱-공통] 300MB 대용량 원본 사진/영상 전송 및 미디어 처리 (예정)
  • ⏳ [#13 | 앱-푸시] 앱이 꺼져 있어도 울리는 푸시 알림(FCM/APNs) 및 보이스톡 연동 (예정)

📂 Part 5. 정식 배포 & 운영

  • ⏳ [#14 | 배포] 백엔드 서버 도커 이미지 빌드 및 유그린 NAS 정식 배포 (예정)
  • ⏳ [#15 | 운영] DB 자동 백업 스크립트 구성 및 가족 실사용 후기 (예정)

댓글 남기기