
KTB 3기 클라우드 과정에서 진행한 개인 커뮤니티 프로젝트의 배포 단계로, 이번엔
Docker를 사용하여 배포하는 과정을 기록한 글입니다.
로컬에서는 절대 보이지 않던 문제들이 서버에서 한꺼번에 터지면서,
‘배포가 진짜 공부구나’ 느끼게 했던 고민과 트러블슈팅도 기록이기도 합니다.
| 구분 | 내용 |
|---|---|
| 로컬 개발 환경 | macOS |
| AWS 인스턴스 | EC2 프리티어 (t3.micro) |
| 사양 | CPU 2vCore / RAM 1GB / EBS 30GB |
| 운영체제(OS) | Ubuntu 24.04 LTS |
| Backend | Spring boot |
| Frontend | Express |
| 데이터베이스(DB) | MySQL |
지난번에 빅뱅배포를 했을때는, 처음 해보는것이다보니 보안적인 부분이나 운영 환경에서 고려해야 할 세부 요소들 보다는 전체적인 흐름을 익히는게 목적이어서 BE/FE의 환경변수를 별도로 분리하지않고 진행을 했습니다.
이번에는 환경변수 분리를 시작으로 로컬테스트 → 서버배포 순으로 진행했고, 로컬 테스트를 위해 작성했던 코드를 기반으로 서버용 코드에는 SSL, .env.prod 등 추가할것들 추가해서 디벨롭했습니다.
docker-compose로 컨테이너를 실행할 예정이라 아래와 같이 폴더 구조를 잡았습니다.
community/
├── community-be/
├── community-fe/
├── nginx/nginx.conf
├── docker-compose.yml
├── .env
be,fe안에 각각 Dockerfile, .dockerignore파일을 작성했습니다.
.dockerignore은 .gitignore파일이랑 비슷하게 작성했고, 서칭하면 자료도 많이 나오니 따로 적진 않겠습니다 ㅎㅎ
be Dockerfile 작성 과정에서 여기저기 찾아보니 Gradle로 jar 생성했다고 가정하고 Docker에서는 따로 빌드하지 않고, 바로 실행하는 코드들이 많이 나와서 시도했지만 서버에서도 이렇게 해도 될까? 라는 생각을 했습니다. 또, 로컬에서 jar 파일을 미리 만들어서 복사하는 방식은 운영환경과 빌드환경이 달라질 수 있다는 문제점이 있었고, CI/CD에도 적합하지 않다고 판단했습니다. 그래서 Docker가 직접 빌드부터 실행까지 수행하도록 멀티 스테이지 빌드를 적용했습니다.
이렇게 빌드단계도 포함시키면, 어디서 실행을 시켜도 동일한 결과가 나온다는 점이 가장 큰 장점으로 느껴졌습니다.
# BE - Dockerfile
# == 1. 빌드단계 ==
# gradle 빌드
FROM gradle:8.5-jdk21 AS builder
# 컨테이너 안에서 작업할 폴더 위치
WORKDIR /app
# Gradle 설정 파일들 먼저 복사
COPY build.gradle settings.gradle ./
COPY gradle gradle
# 나머지 소스 전체 복사
COPY src src
# 스프링부트 JAR 빌드
RUN gradle clean bootJar --no-daemon
# == 2. 실행단계 ==
FROM eclipse-temurin:21-jre
# 실행용 컨테이너 안 작업 폴더
WORKDIR /app
# 1단계에서 만든 JAR 파일만 가져오기
COPY --from=builder /app/build/libs/*.jar app.jar
# 스프링 프로파일
ENV SPRING_PROFILES_ACTIVE=prod
# 컨테이너가 여는 포트
EXPOSE 8080
# 컨테이너가 시작될 때 실행할 명령
ENTRYPOINT ["java", "-jar", "app.jar"]
FE Dockerfile은 아래와 같이 작성했습니다.
# FE - Dockerfile
# Node.js 24 기반의 공식 이미지를 사용
FROM node:24-alpine
# 작업 디렉토리 설정
WORKDIR /usr/src/app
# 의존성 설치 단계의 캐시 활용을 위해 패키지 파일을 먼저 복사
# package.json과 package-lock.json 파일을 현재 작업 디렉토리(./)로 복사
COPY package.json package-lock.json ./
# npm을 사용하여 종속성을 설치
# RUN npm install --omit=dev -> 테스트/개발에만 필요한 패키지는 설치안하겠다는 의미
RUN npm install
# 현재 디렉토리의 모든 파일을 Docker 이미지 내의 작업 디렉토리(WORKDIR)로 복사
# 두 번째 점(.)은 이미지 내의 현재 작업 디렉토리, 즉 WORKDIR로 지정된 위치를 의미
COPY . .
# 운영모드 설정
ENV NODE_ENV=production
# 애플리케이션이 사용할 포트를 노출
EXPOSE 3000
#컨테이너가 실행될 때 앱을 시작
CMD ["npm","start"]
docker-compose는 여러 개의 컨테이너(DB, 백엔드, 프론트, Nginx)를
하나의 애플리케이션처럼 묶어서 한 번에 실행할 수 있게 해주는 도구 입니다.
docker-compose로 실행하게 되면, 기본으로 사용되는 bridge network가 있어서 별도의 설정 없이 컨테이너 간의 통신이 가능합니다.
services:
db:
image: mysql:8.0
container_name: community-mysql
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
MYSQL_DATABASE: ${DB_NAME}
volumes:
- db_data:/var/lib/mysql
command:
- --default-authentication-plugin=mysql_native_password
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
backend:
build:
context: ./community-be
container_name: community-backend
restart: always
depends_on:
- db
environment:
SPRING_PROFILES_ACTIVE: local
DB_HOST: db
DB_PORT: ${DB_PORT}
DB_NAME: ${DB_NAME}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
ports:
- "8080:8080"
frontend:
build:
context: ./community-fe
container_name: community-frontend
restart: always
depends_on:
- backend
environment:
NODE_ENV: ${NODE_ENV}
BACKEND_URL : ${BACKEND_URL} # http://localhost
ports:
- "3000:3000"
nginx:
image: nginx:latest
container_name: community-nginx
restart: always
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
ports:
- "80:80"
depends_on:
- frontend
- backend
volumes:
db_data:
environment에 적힌 ${DB_NAME} 값들은 .env파일에서 값을 주입받게 됩니다.
MySQL 데이터는 컨테이너가 삭제돼도 사라지면 안 되기 때문에 docker volume에 영구 저장합니다.
command는 MySQL커스텀 설정을 적용하는 부분입니다. MySQL은 기본값으로 사용하는 문자셋과 인증 방식이 환경마다 달라서 그대로 사용하면 한글 깨짐, 로그인 오류, 정렬 오류 등이 발생할 수 있다고 합니다. 그래서 아래 설정들을 명시적으로 넣어주면 개발/서버환경을 모두 안정적으로 통일할 수 있습니다.
| command | 값 | 의미 |
|---|---|---|
| default-authentication-plugin | mysql_native_password | Node.js, Spring 등 대부분의 클라이언트가 가장 안정적으로 연결되는 방식으로 MySQL 8.0 기본 인증방식(caching_sha2_password)과의 충돌을 막기 위해 사용 |
| character-set-server | utf8mb4 | 이모지 포함 모든 유니코드 문자를 안전하게 저장 가능하게 하기 위함. 한글·이모지 깨짐 방지. |
| collation-server | utf8mb4_unicode_ci | 사람 언어 기준으로 정렬하고, 대소문자 구분 없이 검색 처리. 언어별 정렬 기준이 자연스럽고, 검색 결과가 안정적임. |
backend, frontend 는 해당 폴더의 Dockerfile을 사용하여 이미지를 빌드해서 사용했습니다. backend는 최소한 db의 컨테이너가 띄어진 이후에 실행되도록 depends_on을 설정했습니다. 그리고 외부 접근을 위해 포트 매핑도 완료했습니다.
nginx는 Reverse Proxy, SSL, Load Balancing 역할을 위해 사용했습니다. nginx에서의 volumes는 bind mount이며, 직접 작성한 nginx.conf 파일을 컨테이너 안으로 덮어써서 커스텀 설정을 적용하기 위한 것입니다. DB 용 volumes는 Docker Volume이며, MySQL 데이터를 영구적으로 보존하기 위해 사용하는 것이므로 둘의 목적이 다릅니다!
그럼 이제 nginx.conf를 작성해보겠습니다. nginx.conf는 일단 개발 환경(local docker-compose) 기준에서 프론트(3000)와 백엔드(8080)를 하나의 “입구(80번 포트)”로 묶는 역할을 합니다.
로컬이기 때문에 일단 http설정만 적었습니다.
events {}
http {
upstream backend {
server backend:8080;
}
upstream frontend {
server frontend:3000;
}
server {
listen 80;
server_name _;
location /api/ {
proxy_pass http://backend;
}
location / {
proxy_pass http://frontend;
}
}
}
proxy_pass → backend(upstream) → backend:8080(컨테이너 DNS) → docker-compose의 backend 서비스
이제 로컬 테스트를 위한 준비는 끝났고 실행을 시켜보도록 하겠습니다.
0.작업환경세팅에서 구성했던 community폴더로 이동해서 도커를 실행시킵니다. 코드를 자주 수정하면서 테스트해야 해서, 변경사항이 항상 이미지에 반영되도록 --build 옵션을 사용했습니다.
community % docker compose up --build
트러블이 안나는게 이상한거 아닙니까.. 어이없는 실수들을 많이했는데, 그런것들을 적어보겠습니다.
Nginx가 켜지지도 않음 → 로그도 안찍힘
MySQL 돌다가 꺼짐 → backend도 돌다가 꺼짐
docker-compose.yml의 db environment 계정이 root가 아니라 user계정을 가르키고 있어서 인증이 안되니까 꺼지는거였습니다. 그래서 root계정으로 접속할 수 있도록 environment를 수정하니 잘돌아갔고, backend 잘돌아갔습니다.Docker는 정상동작했지만, 프론트 → 백엔드 요청 시 서버 오류(500)를 받았던 문제
✔ 1) 필요한 컨테이너를 모두 Docker로 띄워야 함
✔ 2) HTTPS 적용
✔ 2) 외부 네트워크 구성을 고려한 Nginx 라우팅
사진으로 보면, 아래와 같은 흐름으로 진행되는것을 목표로 했습니다.

도메인은 무료 도메인으로 구입했고, 메인도메인과 서브 도메인2개에 EC2의 EIP에 연결했습니다.
Portainer를 도입한 이유는, 서버의 Docker 환경을 UI로 관리하기 위해서입니다.
EC2에서 컨테이너 죽으면 docker ps, docker logs만 보면서 디버깅해야 하는데, Portainer는 이걸 모두 UI로 보여준다는 점이 편했습니다.
Docker desktop이랑은 뭐가 다른거야? 라고 하신다면,
Docker desktop은 로컬에 설치하여 사용하는 것이고, Portainer는 서버환경에서 사용하는것으로, 하나의 서버가 아닌 여러대 서버나 클러스터를 한번에 관리할 수 있다는 것이 특징입니다.
private registry를 도입한 이유는, DockerHub 같은 개인 레지스트리를 만들어서 내 이미지들을 push & pull 해서 프론트엔드와 백엔드 빌드 이미지를 관리하기 위해서 입니다.
이제 진짜 EC2 서버에 실제로 배포해보려고 하는 찰라에 또 고민이 생겼습니다..
“docker-compose.yml과 nginx.conf 같은 인프라 파일들을 어디에서 관리할까?”
처음에는 인프라 파일들을 별도의 infra 레포로 분리해서 관리할지 고민했습니다.
실제 서비스 운영에서는 IaC(Infrastructure as Code) 형태로 인프라를 독립적으로 버전 관리하는 것이 일반적이기도 하고, 개인 프로젝트에서도 이런 구조를 익혀두면 도움이 되기 때문입니다.
하지만 이번 프로젝트의 핵심 목표는 Docker 기반 배포 플로우를 빠르게 실험하고 검증하는 것이었기 때문에, 굳이 지금 단계에서 레포를 분리할 필요는 없다고 판단했습니다.
그래서 우선은 백엔드 레포 내부에 deploy/ 디렉터리를 만들어
docker-compose.yml, nginx.conf, .env.prod 같은 인프라 파일들을 함께 관리하기로 결정했습니다.
프로젝트가 더 확장되어 CI/CD 또는 IaC(Terraform 등)를 도입하게 되면,
그때 infra 레포로 독립 분리해도 충분하다고 보았습니다.
이러한 이유로, 서버에서도 로컬 테스트 때 사용했던 폴더 구조를 그대로 가져가기로 했습니다.
community/
├── community-be/
├── community-fe/
├── nginx/nginx.conf
├── docker-compose.yml
├── .env.prod
이번 딥다이브 주제에 맞게 제 코드들에도 도커 이미지 최적화 기본 전략을 도입해서 아래와 같이 최종 코드를 완성했습니다.
Docker Image 최적화 전략
해당 코드가 어떤 트러블 슈팅을 거쳐서 만들어진것인지는 다음단계에서 소개해드리도록 하겠습니다.
# docker-compose.yml
services:
db:
(생략)
backend:
(생략)
networks:
- community_net
frontend:
(생략)
networks:
- community_net
registry:
image: registry:2
container_name: private-registry
restart: always
volumes:
- registry_data:/var/lib/registry
networks:
- community_net
nginx:
image: nginx:latest
container_name: community-nginx
restart: always
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
- /etc/letsencrypt:/etc/letsencrypt
ports:
- "80:80"
- "443:443"
depends_on:
- frontend
- backend
networks:
- community_net
portainer:
image: portainer/portainer-ce
container_name: portainer
restart: always
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- portainer_data:/data
networks:
- community_net
volumes:
db_data:
portainer_data:
registry_data:
networks:
community_net:
driver: bridge
추가가 된 부분을 위에서부터 설명해보겠습니다.
Port : 실제 배포 환경에서는 backend·frontend·registry 같은 컨테이너 포트를 외부에 직접 열지 않습니다. 외부 요청은 모두 Nginx만 받고, 나머지 서비스는 서버 내부에서만 통신하면 되기 때문입니다. 이 방식이 안전하고, 서비스 구조상 올바른 아키텍쳐 라고 합니다.
Volume : Portainer와 Private Registry는 내부적으로 사용자 설정·이미지 레이어 같은 지속 데이터(state)를 가지고 있기 때문에, 컨테이너가 재시작되더라도 데이터가 유지되도록 전용 Docker Volume을 각각 따로 만들어주었습니다.
Network : 서버에서 모든 컨테이너가 서로 붙으려면 bridge 네트워크를 같게 유지해야 합니다. 하지만, 굳이 적지 않아도 Docker Compose가 자동 네트워크를 생성해주지만 명시적으로 적어주는게 디버깅도 편하고 확장성면에서도 좋다고하여 적어보았습니다.
# nginx.conf
events {}
http {
(생략)
upstream portainer {
server portainer:9000;
}
upstream registry {
server private-registry:5000;
}
# http 요청
server {
listen 80;
server_name domain.com;
# 모든 HTTP 요청을 HTTPS로 이동
return 301 https://$host$request_uri;
}
# https 요청
server {
listen 443 ssl;
server_name domain.com;
# SSL 인증서 설정 (Let's Encrypt)
ssl_certificate /etc/letsencrypt/live/domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/domain.com/privkey.pem;
# SSL 강화 옵션
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
# 프록시 기본 헤더
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
location /api/ {
proxy_pass http://backend;
}
location / {
proxy_pass http://frontend;
}
}
# HTTPS Portainer + Registry 서브도메인
server {
listen 443 ssl;
server_name registry.domain.com;
ssl_certificate /etc/letsencrypt/live/domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/domain.com/privkey.pem;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Portainer가 사용하는 WebSocket 연결을 Nginx가 끊지 않고 정상적으로 프록시하기 위한 설정
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# timeout 길게 설정
proxy_read_timeout 1800;
proxy_send_timeout 1800;
proxy_connect_timeout 1800;
location / {
proxy_pass http://portainer;
}
}
# Private Docker Registry
server {
listen 80;
server_name private.domain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name private.domain.com;
ssl_certificate /etc/letsencrypt/live/domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/domain.com/privkey.pem;
# Docker image push 크기 허용
client_max_body_size 0;
proxy_request_buffering off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
location / {
proxy_pass http://registry;
}
}
}
nginx.conf에는 추가된 부분이 많은데, SSL,Portainer, private registry 등과 관련된 처리가 서버에서는 필요하기 때문입니다.
SSL(HTTPS) : 서버에서 certbot으로 발급받은 SSL 인증서는 단순히 서버에 저장될 뿐이고, nginx에게 “이 인증서를 사용해서 HTTPS 트래픽을 처리해라”라고 직접 알려주기 위해 ssl_certificate / ssl_certificate_key 코드를 적어주었습니다.
HTTP(80) → HTTPS(443) redirect : 모든 요청을 HTTPS로 강제 이동시켜야 보안이 유지되기 때문에, 서버는 항상 HTTPS를 기준으로 동작해야합니다.
프록시 기본 헤더 추가 : Reverse Proxy 환경에서는 서버가 "원래 클라이언트 IP, 프로토콜"을 알 수 없어서 이 헤더들을 넣어줘여 백엔드가 정확한 요청 정보를 인식할 수 있습니다.
Portainer, Private Registry server block 추가
서버에서 서비스를 Docker Compose로 실행할 때 아래 명령을 사용했습니다.
이는 프로덕션 서버에서 전체 인프라(FE/BE/DB/Nginx/Registry/Portainer)를 .env.prod 환경변수와 함께 백그라운드로 실행시키라는 의미입니다.
실행시에 로그를 보고싶으면 마지막에 -d를 빼고 실행시키면 됩니다.
docker compose --env-file .env.prod up -d
서버에서 빌드 실패 → Swap 메모리 임시 확장
Swap 메모리를 추가로 생성해 임시로 메모리를 확장해줬습니다.Swap은 디스크를 RAM처럼 사용하는 방식이라 빌드 과정은 통과할 수 있지만,Swap을 활용했지만, 다음번에는 다른 방식으로 도전해볼 생각입니다.Portainer Timeout
Portainer는 터미널/실시간 로그/이벤트 업데이트에 WebSocket을 사용하는데, WebSocket은 기본 reverse proxy에서 깨진다고 합니다. 그래서 WebSocket 업그레이드를 수동으로 지정해주는 코드를 추가하고 timeout을 길게 해주는 코드를 추가하니 정상 작동했습니다.Private Registry push 실패
Docker image push는 크기가 매우 큰데, 기본 Nginx는 요청 크기 제한때문에 413 오류가 발생하여 client_max_body_size 0 설정을 추가했더니 push를 성공했습니다.“실제 배포 환경에서 왜 이런 문제가 생기는가”를 경험할 수 있었던 과제였습니다. 특히 WebSocket, 아키텍처 차이, 포트 충돌, 업로드 제한, certbot 충돌 같은 이슈들이 로컬에서 테스트해봤을때는 절대 보이지 않던 문제들인데, 배포 환경에서는 쉽게 발생하는 문제들이었습니다.
문제를 하나씩 해결해가면서, 배포 환경이 단순히 “코드만 잘 돌면 된다”의 영역이 아니라, 네트워크·보안·리버스 프록시·인증서·컨테이너 구조 같은 요소들이 유기적으로 맞물려야 한다는 점을 다시 한 번 깨달았습니다.
쉽지 않은 과정이었지만, 그만큼 얻은 것도 많은 것 같습니다.
이제는 기본적인 인프라 구성 흐름을 직접 밟아본 만큼, 앞으로는 더 견고하고, 자동화되고, 신뢰성 있는 배포 환경을 설계해보고 싶습니다.
이번 경험은 다음 단계로 나아가기 위한 좋은 출발점이었다고 생각합니다!