Docker로 NestJS와 PostgreSQL 배포하기 — 컨테이너가 필요한 이유
Docker와 Docker Compose의 기초 개념부터 pnpm 모노레포 NestJS 이미지 빌드, EC2에 실제 배포하는 과정까지 정리합니다.
이전 글에서 AWS EC2 인스턴스를 생성하고 SSH 접속까지 마쳤습니다.
이제 NestJS 서버와 PostgreSQL을 올릴 차례입니다.
로컬에서는 되는데 서버에서 왜 안 되지?
서버에 Node.js를 직접 설치해서 올리면 안 될까요?
이 글에서는 Docker가 어떤 문제를 해결하는지, 그리고 pnpm 모노레포 NestJS 프로젝트를 EC2에 컨테이너로 배포하는 전 과정을 설명서 수준으로 정리합니다.
Docker가 필요한 이유
”제 컴퓨터에서는 되는데요”
소프트웨어 개발의 고전적인 문제입니다.
로컬에서 완벽히 동작하던 서버가 EC2에서 오류를 냅니다.
원인은 환경의 차이입니다.
- Node.js 버전이 다릅니다. (로컬: v22, EC2: v18)
- 패키지 의존성이 미묘하게 다릅니다.
- 운영체제와 아키텍처가 다릅니다. (Mac ARM vs Ubuntu x86)
- 환경 변수가 맞지 않습니다.
서버에 Node.js와 pnpm을 직접 설치하면 이 문제가 완전히 해결되지 않습니다.
서버 환경을 로컬과 100% 일치시키기 어렵고, 버전을 업그레이드할 때 기존 서비스에 영향을 줍니다.
Docker가 해결하는 것
Docker는 애플리케이션과 그 실행 환경을 하나의 컨테이너로 묶는 기술입니다.
컨테이너 안에는 코드, 런타임, 라이브러리, 환경 설정이 모두 들어 있습니다.
컨테이너는 어디서 실행해도 동일하게 동작합니다.
로컬에서 테스트한 컨테이너를 그대로 EC2에 올릴 수 있습니다.
Docker 없이: Docker 사용:
로컬 → Node 22, pnpm 10 어디서든 동일한 컨테이너 실행
EC2 → Node 18, npm 동일한 Node 버전, 동일한 의존성
→ 환경 불일치 오류 발생 → "내 컴퓨터에서는 되는데" 해결
Docker 기초 개념
이미지 · 컨테이너 · Dockerfile
이미지 (Image)
이미지는 컨테이너를 만들기 위한 읽기 전용 틀입니다.
코드, 런타임, 라이브러리, 환경 설정이 모두 담겨 있습니다.
붕어빵 틀처럼, 이미지 하나로 동일한 컨테이너를 몇 개든 만들 수 있습니다.
이미지는 레이어(layer)로 구성됩니다.
각 레이어는 이전 레이어 위에 변경 사항을 쌓는 방식입니다.
Docker는 레이어를 캐싱하므로, 변경이 없는 레이어는 재실행하지 않습니다.
컨테이너 (Container)
컨테이너는 이미지를 실제로 실행한 인스턴스입니다.
이미지가 틀이라면, 컨테이너는 그 틀로 만든 실행 결과물입니다.
컨테이너는 호스트 OS와 격리된 환경에서 실행됩니다.
자체 파일시스템, 프로세스 공간, 네트워크를 가집니다.
컨테이너가 종료되면 그 안의 변경 사항은 기본적으로 사라집니다. (볼륨을 쓰면 영속화 가능)
Dockerfile
Dockerfile은 이미지를 만드는 설계도입니다.
명령어를 순서대로 나열하면, Docker가 이를 읽어 이미지를 생성합니다.
FROM node:20-alpine # 베이스 이미지 선택 (Node.js 20 + 경량 Alpine Linux)
WORKDIR /app # 컨테이너 내부 작업 디렉터리 지정
COPY package.json ./ # 파일 복사 (호스트 → 컨테이너)
RUN npm install # 명령 실행 (레이어 하나 생성)
COPY . . # 나머지 소스 복사
EXPOSE 3000 # 포트 문서화 (실제 개방은 docker run -p 에서)
CMD ["node", "dist/main.js"] # 컨테이너 기동 시 실행할 명령
docker build -t my-app . 명령으로 Dockerfile을 읽어 이미지를 빌드합니다.
-t my-app은 이미지에 이름(태그)을 붙이는 옵션입니다.
레이어 캐시와 COPY 순서
Docker는 각 명령을 레이어로 캐싱합니다.
한 레이어가 변경되면 그 아래의 모든 레이어를 재실행합니다.
이를 활용해 의존성 설치를 캐싱하는 패턴이 있습니다.
# 변경이 적은 파일부터 먼저 복사
COPY package.json package-lock.json ./
RUN npm install # package.json이 바뀌지 않으면 이 레이어 캐시 재사용
COPY . . # 소스가 바뀌어도 install 레이어에는 영향 없음
RUN npm run build
소스 코드가 바뀌어도 package.json이 동일하다면 npm install 레이어가 캐시에서 재사용됩니다.
빌드 시간이 대폭 줄어드는 패턴입니다.
단, pnpm 모노레포에서는 이 패턴이 workspace symlink를 파괴하는 부작용이 있습니다.
이 내용은 다음 글의 트러블슈팅 섹션에서 자세히 다룹니다.
멀티스테이지 빌드 — 결과물만 들고 이사
TypeScript 컴파일러나 devDependencies는 빌드할 때만 필요합니다.
프로덕션 이미지에 개발 도구가 들어가면 이미지 크기가 불필요하게 커집니다.
멀티스테이지 빌드는 이 문제를 해결하는 패턴입니다.
빌드용 스테이지(builder)에서 컴파일을 마치고, 실행 스테이지(runner)에서는 빌드 산출물만 가져와 경량 이미지를 구성합니다.
# 1단계: 빌드 (TypeScript 컴파일러, devDependencies 포함)
FROM node:20-alpine AS builder
WORKDIR /app
COPY . .
RUN npm install && npm run build
# 2단계: 실행 (개발 도구 없이 빌드 결과물만)
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/main.js"]
AS builder로 스테이지에 이름을 붙이고, COPY --from=builder로 이전 스테이지의 파일을 선택적으로 가져옵니다.
최종 이미지에는 실행에 필요한 파일만 담깁니다.
Docker Compose와 네트워크
Docker Compose
서버와 DB를 각각 docker run으로 띄우면 명령이 길어지고 관리가 어렵습니다.
Docker Compose는 여러 컨테이너를 docker-compose.yml 하나로 정의하고 docker compose up 한 줄로 기동하는 도구입니다.
services:
postgresql:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: todo_db
volumes:
- ./postgres-data:/var/lib/postgresql/data # 데이터 영속화
ports:
- "5432:5432"
volumes는 컨테이너가 삭제되어도 데이터를 유지하기 위한 설정입니다.
./postgres-data(호스트 디렉터리)를 컨테이너 내부 DB 데이터 경로에 마운트합니다.
Docker 네트워크 — 컨테이너끼리 통신하는 방
컨테이너는 기본적으로 격리된 네트워크 공간에 있습니다.
docker run으로 각각 띄운 API 서버와 DB는 서로를 찾을 수 없습니다.
Docker는 사용자 정의 브리지 네트워크로 이 문제를 해결합니다.
같은 네트워크에 속한 컨테이너끼리는 컨테이너 이름(또는 Compose 서비스명)으로 DNS 통신이 가능합니다.
# 네트워크 생성
docker network create todo-network
# 같은 네트워크에서 두 컨테이너 실행
docker run --network todo-network --name postgresql postgres:16-alpine
docker run --network todo-network --name api my-api
# api 컨테이너 안에서 'postgresql' 호스트명으로 DB에 접근 가능
# → .env에서 DB_HOST=postgresql 이 동작하는 이유
Docker Compose로 띄운 컨테이너는 자동으로 같은 네트워크에 묶입니다.
Compose 외부에서 docker run으로 띄운 컨테이너는 --network를 명시해야 합니다.
이 차이를 모르면 DB_HOST=postgresql을 아무리 맞춰도 ENOTFOUND 에러가 납니다.
pnpm 모노레포 NestJS Dockerfile 설계
최종 Dockerfile과 각 결정의 이유
이번 프로젝트는 pnpm workspace 모노레포입니다.
todo-apps/
├── apps/
│ └── server/ ← NestJS 서버
├── packages/
│ └── schemas/ ← @repo/schemas (Zod 스키마 공유 패키지)
├── pnpm-workspace.yaml
└── pnpm-lock.yaml
@repo/schemas는 "type": "module", "exports": { ".": "./dist/index.js" }인 ESM 패키지입니다.
prepare 스크립트가 tsc를 실행하므로 pnpm install 시점에 dist/를 빌드하려 합니다.
apps/server는 이 schemas를 workspace 의존성으로 사용합니다.
이 구조가 일반적인 단일 패키지와 달리 Dockerfile 설계에 추가 제약을 줍니다.
결론부터 보면, 최종 Dockerfile은 다음과 같습니다.
# ── Stage 1: build ────────────────────────────────────────
FROM node:20-alpine AS builder
# bcrypt 등 네이티브 모듈 컴파일에 필요한 빌드 도구
RUN apk add --no-cache python3 make g++
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
WORKDIR /app
# ⚠️ 소스 전체를 install 이전에 복사 (이유: 아래 설명)
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
COPY packages/schemas ./packages/schemas
COPY apps/server ./apps/server
RUN pnpm install --frozen-lockfile
RUN pnpm --filter server build
# prod 의존성을 단일 디렉터리로 추출
# pnpm deploy는 dist를 자동 포함하지 않으므로 cp로 명시 복사
RUN pnpm deploy --filter=server --prod --legacy /prod/server \
&& cp -r apps/server/dist /prod/server/dist
# ── Stage 2: production ────────────────────────────────────
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /prod/server ./
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/main.js"]
왜 소스 전체를 install 이전에 복사하는가
pnpm workspace는 apps/server/node_modules/@repo/schemas를 ../../../../packages/schemas로 향하는 심볼릭 링크로 만듭니다.
서버가 @repo/schemas를 import할 수 있는 것은 이 symlink 덕분입니다.
일반적인 레이어 캐시 최적화 패턴에서는 package.json만 먼저 복사하고 install 후에 소스를 복사합니다.
# 이렇게 하면 symlink가 파괴됨
COPY apps/server/package.json ./apps/server/
RUN pnpm install # symlink 생성됨
COPY apps/server ./apps/server # ← 이 COPY가 node_modules를 포함한 디렉터리를 덮어씀
# symlink 사라짐 → @repo/schemas 찾을 수 없음
따라서 소스 전체를 install 이전에 복사해야 합니다.
install 시점에 디렉터리 구조가 완성되어 있으므로 symlink가 정상 생성되고, 이후 COPY로 덮어쓰이지 않습니다.
레이어 캐시 효율은 다소 떨어지지만, workspace 구조 보존이 우선입니다.
왜 bcrypt 빌드 도구가 필요한가
bcrypt는 npm 패키지이지만, 설치 시 C++ 네이티브 바이너리를 컴파일합니다.
node:20-alpine 베이스 이미지에는 컴파일에 필요한 도구(python3, make, g++)가 없습니다.
RUN apk add --no-cache python3 make g++
이 줄이 없으면 pnpm install 중 bcrypt 컴파일에서 에러가 납니다.
pnpm deploy — 배포 단위 추출
runner 스테이지에서 전체 모노레포 node_modules를 가져오면 이미지가 거대해집니다.
pnpm deploy는 특정 패키지에 필요한 prod 의존성만 단일 디렉터리로 추출합니다.
runner에서 pnpm install을 재실행할 필요가 없습니다.
RUN pnpm deploy --filter=server --prod --legacy /prod/server
--filter=server: apps/server 패키지만 대상
--prod: devDependencies 제외
--legacy: pnpm v10의 workspace 패키지 deploy 정책 변경으로 필요한 플래그
deploy 후에는 dist/를 별도로 복사합니다.
pnpm deploy는 패키지 파일과 node_modules를 담당하며, nest build가 생성한 dist는 자동 포함되지 않습니다.
RUN pnpm deploy --filter=server --prod --legacy /prod/server \
&& cp -r apps/server/dist /prod/server/dist
.dockerignore — 무엇을 제외해야 하는가
.dockerignore는 Docker 빌드 컨텍스트에서 제외할 파일을 지정합니다.
빌드 컨텍스트는 docker build . 실행 시 Docker 데몬에 전송되는 파일 집합입니다.
.gitignore와 별개 파일이며, .gitignore를 자동으로 읽지 않습니다.
gitignore에 있는 파일도 .dockerignore에 별도로 추가해야 합니다.
node_modules
**/node_modules
dist
**/dist
*.tsbuildinfo ← 이 줄이 없으면 TypeScript 빌드가 조용히 망가짐
**/*.tsbuildinfo
.git
.env
**/.env
coverage
**/*.spec.ts
*.tsbuildinfo를 제외해야 하는 이유는 중요합니다.
TypeScript incremental 빌드는 tsconfig.build.tsbuildinfo에 이전 빌드 상태를 기록합니다.
다음 빌드 시 이 파일을 읽어 “변경이 없으면 다시 emit하지 않는다”고 판단합니다.
.dockerignore에 dist만 제외하고 *.tsbuildinfo를 남겨두면 다음 상황이 만들어집니다.
로컬 파일시스템:
packages/schemas/dist/ → .dockerignore로 제외됨
packages/schemas/tsconfig.build.tsbuildinfo → .dockerignore에 없어 포함됨
Docker 컨테이너 안:
dist/ 없음 + tsbuildinfo 존재
→ tsc가 "이미 최신 빌드" 판단
→ emit 없이 exit 0
→ dist/index.js 미생성
→ @repo/schemas 모듈 해석 실패
dist와 tsbuildinfo는 반드시 쌍으로 제외해야 합니다.
Docker Hub에 이미지 push하기
EC2에서 직접 이미지를 빌드할 수도 있지만, 이미지를 레지스트리에 올리고 서버에서 pull하는 방식이 일반적입니다.
빌드 환경과 실행 환경이 분리되고, 이미지 버전 관리가 가능합니다.

Docker Hub는 공식 무료 이미지 레지스트리입니다.
hub.docker.com에서 계정을 만들고 사용합니다.
# 1. 로컬에서 이미지 빌드
docker build -t [username]/todo-server:latest .
# 2. Docker Hub 로그인
docker login
# 3. 이미지 push
docker push [username]/todo-server:latest
EC2에서 pull합니다.
docker pull [username]/todo-server:latest
이미지는 latest 태그 외에 버전이나 커밋 해시를 붙여 관리하면 롤백이 쉬워집니다.
EC2에 배포하기
실제 배포 순서
EC2에 SSH로 접속한 상태에서 순서대로 진행합니다.
1단계 — Docker 설치
# Docker Engine 공식 설치 스크립트
curl -fsSL https://get.docker.com | sudo sh
# ubuntu 사용자에게 docker 권한 부여 (sudo 없이 docker 명령 사용)
sudo usermod -aG docker ubuntu
# 재로그인 (권한 적용을 위해 필수)
exit
ssh [연결 명령]
설치 확인입니다.
docker --version
# Docker version 27.x.x, build ...
docker info
# 정상이면 서버 정보 출력

2단계 — 배포 디렉터리 및 환경 파일 준비
mkdir -p /home/ubuntu/apps
/home/ubuntu/apps/.env 파일을 생성합니다.
민감 정보가 담기므로 Git에 올리지 않고 서버에 직접 작성합니다.
cat > /home/ubuntu/apps/.env << 'EOF'
NODE_ENV=production
DB_HOST=postgresql
DB_PORT=5432
DB_USERNAME=postgres
DB_PASSWORD=your-db-password
DB_NAME=todo_db
JWT_SECRET=your-jwt-secret-key
JWT_EXPIRES_IN=1h
PORT=3000
EOF
DB_HOST=postgresql은 Docker 네트워크 안에서 서비스명으로 DB를 찾는 주소입니다.
이 값은 EC2용 docker-compose.yml의 services 키(postgresql)와 일치해야 합니다.
3단계 — EC2용 docker-compose.yml 작성
로컬 개발용 docker-compose.yml과 운영 서버용은 반드시 분리해야 합니다.
비밀번호, 볼륨 경로, 네트워크 설정이 다릅니다.
/home/ubuntu/apps/docker-compose.yml을 작성합니다.
version: "3"
services:
postgresql:
image: postgres:16-alpine
restart: unless-stopped
container_name: postgres
ports:
- "15432:5432" # 호스트 포트 15432 → 컨테이너 5432
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: your-db-password
POSTGRES_DB: todo_db
volumes:
- ./postgres-data:/var/lib/postgresql/data # 데이터 영속화
networks:
- todo-network
networks:
todo-network:
name: todo-network # 이름을 고정해야 docker run --network todo-network 로 참조 가능
name: todo-network로 네트워크 이름을 고정하는 것이 중요합니다.
Compose가 자동 생성하는 네트워크 이름은 디렉터리명이 prefix로 붙어 예측하기 어렵습니다.
4단계 — PostgreSQL 기동
cd /home/ubuntu/apps
docker compose up -d
-d는 백그라운드 실행(detached mode) 옵션입니다.
기동 상태를 확인합니다.
docker ps
# CONTAINER ID IMAGE COMMAND STATUS
# xxxxxxxxxxxx postgres:16-alpine "docker-entrypoint.s…" Up 30 seconds
docker logs postgres
# PostgreSQL init process complete; ready for start up.
# database system is ready to accept connections

5단계 — NestJS 서버 이미지 pull & run
# Docker Hub에서 이미지 pull
docker pull [username]/todo-server:latest
# 컨테이너 실행
docker run -d \
--name todo-server \
--network todo-network \ # PostgreSQL과 같은 네트워크에 묶음
-p 8080:3000 \ # 호스트 8080 → 컨테이너 3000
--env-file /home/ubuntu/apps/.env \ # .env 파일에서 환경 변수 주입
-e NODE_ENV=production \ # .env의 값보다 우선 적용 (devDependency 크래시 방지)
-e TZ=Asia/Seoul \ # 로그 타임존
--restart unless-stopped \ # 서버 재시작 시 컨테이너 자동 기동
[username]/todo-server:latest
주요 옵션을 정리합니다.
| 옵션 | 설명 |
|---|---|
--network todo-network | Compose가 만든 네트워크에 합류. DB_HOST=postgresql DNS 해석 가능 |
-p 8080:3000 | 호스트 8080포트로 들어온 트래픽을 컨테이너 3000포트로 전달 |
--env-file | .env 파일의 키-값을 환경 변수로 주입 |
-e NODE_ENV=production | —env-file보다 우선 적용. pino-pretty 등 dev-only 모듈 비활성화 |
--restart unless-stopped | 컨테이너 크래시 시 자동 재시작. docker stop으로 명시 중지하면 재시작 안 함 |
6단계 — 기동 확인
# 실행 중인 컨테이너 목록
docker ps
# 로그 스트리밍
docker logs -f todo-server
로그에서 'Nest application successfully started'가 보이면 성공입니다.
[Nest] LOG [NestApplication] Nest application successfully started +Xms

브라우저에서 http://[공인-IPv4]:8080/api로 접속해 응답을 확인합니다.

컨테이너 관리 명령어 모음
앞으로 자주 쓸 명령어를 정리합니다.
# 실행 중인 컨테이너 확인
docker ps
# 모든 컨테이너 확인 (중지된 것 포함)
docker ps -a
# 로그 확인 (마지막 50줄)
docker logs todo-server --tail 50
# 로그 실시간 스트리밍
docker logs -f todo-server
# 컨테이너 중지
docker stop todo-server
# 컨테이너 삭제
docker rm todo-server
# 이미지 목록
docker images
# 사용하지 않는 이미지 삭제
docker image prune -f
# 컨테이너 안으로 접속 (디버깅용)
docker exec -it todo-server sh
마치며
Docker를 처음 써봤을 때 “이미지와 컨테이너 차이가 뭔지”부터 막혔습니다.
직접 배포해보고 나서야 왜 Docker가 필요한지가 체감됐습니다.
이번 배포를 통해 확실히 알게 된 것 두 가지입니다.
- 환경 불일치 문제는 Docker 컨테이너로 해결합니다. 한 번 만든 이미지는 어디서든 동일하게 동작합니다.
- DB_HOST를 아무리 잘 맞춰도, 두 컨테이너가 같은 Docker 네트워크에 없으면 연결이 불가능합니다.
지금은 소스가 바뀔 때마다 EC2에 SSH 접속해서 docker pull && docker run을 직접 치고 있습니다.
이 작업을 반복하는 것은 번거롭고, 배포를 잊으면 서버에 구버전이 돌게 됩니다.
다음 글에서는 GitHub Actions로 코드를 push할 때 자동으로 테스트하고 배포하는 CI/CD 파이프라인을 연결합니다.
그 과정에서 겪은 삽질도 전부 기록합니다.