Docker and Jenkins Deploy Pipeline
컨테이너 레지스트리도 CI 러너도 없는 고객사 서버에 Next.js 사이트와 React CMS를 올려야 했습니다. LG Shield에서는 로컬에서 이미지를 만들어 tar 파일로 보내고 서버에서 Docker Compose와 Nginx로 띄우는 셸 스크립트 하나로 배포했고 Jenkins는 검토 단계에서 컨테이너로 띄워 보는 데서 멈췄습니다. 이 글은 이미지 빌드, 환경 값 주입, 전송, 실행, 프록시 다섯 단계마다 무엇을 빌드 시점에 고정하고 무엇을 실행 시점에 남겼는지 정리합니다. 예제는 2026년 8월과 9월에 갱신된 Next.js 문서와 공식 Docker 예제, Docker와 Nginx, Jenkins 문서로 다시 확인해 썼고 프로젝트 파일을 그대로 옮기지 않았습니다.
제약이 선택지를 줄였습니다
이미지를 서버에 보내는 방법은 셋이었습니다. 각각 서버 밖에 무엇이 있어야 하는지가 달랐습니다.
| 방법 | 필요한 것 | 걸린 문제 |
|---|---|---|
| 레지스트리에 push하고 서버에서 pull | 컨테이너 레지스트리와 접근 권한 | 레지스트리가 없었고 이 프로젝트에서 새로 두지 않았습니다 |
| 서버에서 소스를 clone해 빌드 | 서버의 저장소 접근 권한과 빌드 자원 | 운영 인스턴스가 빌드 부하를 떠안고 소스가 운영 서버에 남습니다 |
| 로컬에서 빌드한 이미지를 tar로 전송 | SSH 접속과 scp | 전송 용량이 크고 배포하는 사람의 환경에 의존합니다 |
세 번째를 골랐습니다. 서버에 필요한 것이 Docker 데몬과 SSH뿐이고 이전 프로젝트의 Java API 배포 스크립트가 같은 순서로 돌고 있어 절차를 새로 만들 필요가 없었습니다. 대신 전송 용량과 로컬 환경 의존이라는 비용을 다단계 이미지와 플랫폼 고정으로 줄여야 했습니다.
flowchart LR
Build["1. 이미지 빌드<br/>다단계, linux/amd64"] --> Env["2. 환경 값<br/>빌드에 박힐 것과 실행 때 읽을 것"]
Env --> Ship["3. 전송<br/>docker save, scp"]
Ship --> Run["4. 실행<br/>docker load, compose up"]
Run --> Proxy["5. 프록시<br/>Nginx가 80으로 받아 3000으로"]이미지는 두 단계로 만들고 standalone만 담습니다
Next.js의 output: 'standalone'은 빌드 때 각 페이지가 실제로 읽는 파일만 추적해 .next/standalone에 모읍니다. 문서는 이 폴더를 node_modules를 설치하지 않고도 그대로 배포할 수 있다고 설명하고 next start 대신 쓸 최소 server.js도 함께 만듭니다. 다만 public과 .next/static은 CDN이 맡는 편이 낫다는 이유로 기본으로 복사되지 않으므로 Dockerfile에서 직접 옮겨야 합니다. Next.js: output
# 공식 예제는 작성 시점의 LTS를 ARG로 고정합니다. 올릴 때는 이 값 하나만 바꿉니다
ARG NODE_VERSION=24-slim
FROM node:${NODE_VERSION} AS deps
WORKDIR /app
# 잠금 파일만 먼저 복사해야 소스가 바뀌어도 설치 레이어가 캐시에서 재사용됩니다
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund
FROM node:${NODE_VERSION} AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NODE_ENV=production
# NEXT_PUBLIC_ 값은 이 단계에서 번들에 박힙니다. 환경마다 이미지를 따로 만드는 이유입니다
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
RUN npm run build
FROM node:${NODE_VERSION} AS runner
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
# standalone은 public과 .next/static을 담지 않으므로 직접 복사합니다
COPY --from=builder --chown=node:node /app/public ./public
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
CMD ["node", "server.js"]마지막 FROM만 최종 이미지가 되고 앞 단계는 복사해 온 파일을 빼면 버려집니다. Docker 문서는 이 방식의 목적을 빌드 도구가 없는 작은 운영 이미지로 설명합니다. HOSTNAME=0.0.0.0이 빠지면 server.js가 컨테이너 안의 localhost에만 묶여 Nginx가 닿지 못합니다. 공식 예제처럼 node 사용자로 전환해 두면 컨테이너가 뚫려도 root 권한이 남지 않습니다. Docker: Multi-stage builds, with-docker 예제
CMS는 Vite 정적 빌드라 두 번째 단계가 Node가 아니라 nginx:stable-alpine입니다. 빌드 결과를 Nginx 문서 루트에 복사하고 try_files $uri /index.html로 클라이언트 라우팅을 받습니다. 사이트와 CMS는 같은 스크립트로 배포되지만 컨테이너 안에서는 하나는 Node 서버가 돌고 다른 하나는 정적 파일 서버가 돕니다.
Apple Silicon에서 만든 이미지는 기본값이 linux/arm64라 x86 서버에서 실행되지 않습니다. docker build --platform linux/amd64를 고정해 두면 BuildKit이 QEMU로 x86을 흉내 내 빌드합니다. Docker 문서도 이 에뮬레이션이 특히 컴파일과 압축에서 네이티브보다 훨씬 느릴 수 있다고 경고하므로 길어지는 빌드 시간이 이 방식의 비용입니다. Docker: Multi-platform builds
이미지 태그는 latest 대신 저장소의 버전 파일 값을 씁니다. 첫 배포 스크립트는 latest로 올렸는데 서버에서 어떤 버전이 도는지 이미지 목록만 봐서는 알 수 없어 다음 날 버전 파일 태그로 바꿨습니다. ARG VERSION을 받아 LABEL version=${VERSION}으로 이미지 메타데이터에도 남겨 두면 docker inspect로도 확인됩니다.
환경 값은 빌드에 박히는 것과 실행 때 읽는 것을 나눕니다
Next.js 문서는 NEXT_PUBLIC_ 접두사가 붙은 값을 next build 시점에 브라우저 번들에 하드코딩한다고 설명합니다. 빌드가 끝난 앱은 이 값의 변경에 더는 반응하지 않으므로 이미지 하나를 여러 환경으로 승격하려면 그 값이 환경마다 같아야 합니다. 반대로 서버에서만 읽는 값은 동적 렌더링 중에 실행 시점의 process.env를 읽습니다. Next.js: Environment Variables
| 값 | 어디서 정해지는가 | 넣는 곳 |
|---|---|---|
API 주소처럼 브라우저가 쓰는 NEXT_PUBLIC_ 값 | 빌드 | Dockerfile의 ARG, 환경마다 이미지 분리 |
| 서버 전용 키, 내부 서비스 주소 | 실행 | Compose의 environment나 env_file |
| 비밀값 | 빌드에 필요하면 --secret 마운트, 실행에 필요하면 서버의 env 파일 | 이미지 레이어에 남기지 않음 |
처음 검토한 구성안은 ARG ENV_FILE로 .env.dev, .env.stage 중 하나를 골라 COPY ${ENV_FILE} .env로 이미지에 넣는 방식이었습니다. 환경별 값을 고르는 데는 편하지만 .env 파일 전체가 이미지 레이어에 남습니다. Docker 문서는 빌드 인자와 환경 변수가 비밀값을 넘기기에 부적절하다고 경고하고 빌드 중에만 보이는 RUN --mount=type=secret을 안내합니다. 브라우저에 나가도 되는 값만 ARG로 받고 나머지는 서버의 env 파일로 돌렸습니다. Docker: Build secrets
Compose에서는 역할이 다른 파일 두 종류를 구분합니다. 프로젝트 폴더의 .env는 Compose 파일 안의 ${VERSION} 같은 자리를 채우는 보간용이고 서비스의 env_file이 컨테이너 환경에 들어갑니다. .env 값은 서비스가 참조해야만 컨테이너에 닿습니다. Compose: Set environment variables, Compose: Interpolation
# version 항목은 더 이상 쓰지 않습니다. 있으면 Compose가 폐기됐다는 경고를 냅니다
services:
web:
image: site:${VERSION} # 보간: 배포 스크립트가 VERSION을 export합니다
env_file: envs/env.prod # 컨테이너 환경: 서버 전용 값
restart: unless-stopped
expose: ['3000'] # 바깥에는 열지 않고 Nginx만 접근합니다
proxy:
image: nginx:stable-alpine
ports: ['80:80']
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on: [web]envs/env.dev, env.stage, env.prod는 같은 키를 가진 세 파일이고 스크립트의 프로파일 인자가 어느 파일을 보낼지 고릅니다. 세 파일 모두 저장소 밖에서 관리하고 이미지에는 넣지 않습니다. Compose: version and name
전송과 실행은 스크립트 하나가 맡습니다
docker save는 이미지를 tar 스트림으로 내보내고 docker load는 gzip으로 압축된 파일도 그대로 읽습니다. 그 사이를 scp가 잇습니다. 스크립트는 프로파일 인자를 검증한 뒤 변수를 정하고 빌드, 저장, 전송, 원격 실행을 차례로 합니다. Docker: docker image save, Docker: docker image load
#!/usr/bin/env bash
set -euo pipefail # 어느 단계든 실패하면 다음 단계로 넘어가지 않습니다
profile="${1:-}"
case "$profile" in
dev) compose="compose.dev.yml" ;;
prod) compose="compose.prod.yml" ;;
*) echo "usage: $0 dev|prod"; exit 1 ;;
esac
app="site"
version="$(tr -d '[:space:]' < version.txt)"
image="${app}:${version}"
# 호스트와 키는 환경 변수로 받습니다. 스크립트와 저장소에는 적지 않습니다
remote="deploy@${DEPLOY_HOST:?}"
key="${DEPLOY_KEY_PATH:?}"
docker build --platform linux/amd64 \
--build-arg VERSION="$version" \
--build-arg NEXT_PUBLIC_API_URL="$(grep '^NEXT_PUBLIC_API_URL=' "envs/env.${profile}" | cut -d= -f2-)" \
-t "$image" -f docker/prod.dockerfile .
docker save "$image" | gzip > "${app}.tar.gz"
scp -i "$key" "${app}.tar.gz" "docker/${compose}" "docker/nginx.conf" "envs/env.${profile}" "${remote}:~/${app}/"
rm "${app}.tar.gz"
ssh -i "$key" "$remote" APP="$app" VERSION="$version" COMPOSE="$compose" 'bash -s' <<'REMOTE'
set -euo pipefail
cd ~/"$APP"
export VERSION
docker compose -f "$COMPOSE" down
docker load < "${APP}.tar.gz"
docker compose -f "$COMPOSE" up -d
docker image prune -f # 태그가 없는 이전 레이어만 지웁니다. 이전 버전 태그는 남습니다
REMOTE전송 목록에 Compose 파일과 Nginx 설정, env 파일이 함께 있는 이유는 서버에 소스가 없기 때문입니다. 볼륨으로 마운트하는 파일은 모두 이 목록에 들어가야 하고 마운트할 폴더의 소유자가 배포 계정과 다르면 컨테이너가 읽지 못합니다. 오류 페이지를 Nginx 정적 파일로 두려던 시도에서는 docker inspect의 Mounts로 마운트 경로를 확인하고 원격 폴더의 소유자와 권한을 맞추는 명령이 메모에 남았습니다.
실행 뒤 확인은 docker compose ps로 상태, docker compose logs -f web으로 기동 로그, 그리고 locale 미들웨어 밖에 둔 health check 경로를 curl로 호출하는 세 가지입니다. 서버에는 플러그인 형태의 docker compose를 설치합니다. Python으로 만든 v1의 docker-compose는 더 이상 지원 대상이 아니라서 설치 메모에서도 하이픈 명령은 alias로만 남겼습니다. Compose: History
Jenkins는 같은 스크립트를 부르는 자리였습니다
착수 때 두 앱의 배포 항목에 Jenkins가 있었습니다. 로컬에 Jenkins 컨테이너를 띄워 초기 관리자 비밀번호를 로그에서 꺼내는 데까지 해 봤고 브랜치별로 env 파일을 고른 뒤 SSH로 서버에 들어가 Compose를 다시 올리는 파이프라인 구성안을 검토했습니다. 최종 배포는 Jenkins 없이 위 스크립트를 직접 실행하는 방식으로 남았고 Jenkins를 두지 않기로 한 이유는 기록에 없습니다.
그래도 스크립트가 프로파일 인자 하나로 모든 단계를 포함하도록 둔 덕분에 Jenkins 단계는 짧습니다. Jenkins 문서 기준으로 공식 jenkins/jenkins 이미지에는 Docker CLI가 없어 이미지를 만들려면 docker:dind 컨테이너를 옆에 두고 DOCKER_HOST로 연결해야 합니다. 서버 접속 키는 withCredentials의 sshUserPrivateKey가 빌드 동안만 임시 파일로 풀어 주고 끝나면 지웁니다. 문서는 같은 노드의 다른 빌드가 그 파일을 읽을 수 있다고 경고하므로 배포 노드는 실행기 하나로 둡니다. Jenkins: Installing Jenkins with Docker, Jenkins: Credentials Binding plugin
pipeline {
agent any
environment {
PROFILE = "${env.BRANCH_NAME == 'main' ? 'prod' : 'dev'}"
}
stages {
stage('Deploy') {
steps {
// 호스트와 키는 Jenkins 자격 증명 저장소에서 꺼내 스크립트가 읽는 변수 이름으로 넘깁니다
withCredentials([
string(credentialsId: "deploy-host-${PROFILE}", variable: 'DEPLOY_HOST'),
sshUserPrivateKey(credentialsId: "deploy-key-${PROFILE}", keyFileVariable: 'DEPLOY_KEY_PATH'),
]) {
sh './deploy.sh "$PROFILE"'
}
}
}
}
}수동 배포와 Jenkins 배포가 같은 스크립트를 쓰면 두 경로의 결과가 다를 일이 없습니다. 파이프라인이 하는 일은 브랜치를 프로파일로 바꾸고 자격 증명을 꺼내 주는 것뿐입니다. 반대로 Jenkins 단계 안에 docker build와 ssh 명령을 풀어 쓰기 시작하면 급할 때 손으로 돌리는 절차와 어긋나기 시작합니다.
Nginx가 앞에 서고 오류의 경계를 정합니다
Nginx 컨테이너가 80 포트로 받아 Compose 네트워크 안의 web:3000으로 넘깁니다. proxy_pass만 두면 Nginx는 기본으로 Host 헤더를 프록시 대상 주소로 바꿔 보내므로 Next.js가 원래 호스트를 알아야 하면 proxy_set_header Host $host를 직접 적습니다. Nginx: ngx_http_proxy_module
# 이 파일은 conf.d에 마운트되어 기본 설정의 http 블록 안에 들어갑니다. http 블록을 또 열면 실패합니다
server {
listen 80;
server_name example.com;
server_tokens off; # 오류 페이지와 Server 헤더에서 버전을 뺍니다
# 앱이 응답하지 못한 경우만 Nginx가 답합니다
error_page 502 503 504 /maintenance.html;
location = /maintenance.html { root /usr/share/nginx/html; internal; }
location / {
proxy_pass http://web:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}오류 응답의 경계는 한 번 틀렸던 부분입니다. 보안 점검에서 서버 버전 숨김과 오류 페이지 정리를 요구받았을 때 첫 시도는 Nginx에 4xx와 5xx error_page를 모두 걸고 고정 HTML 두 개를 두는 것이었습니다. 그런데 Nginx 문서대로 proxy_intercept_errors는 기본이 off라 프록시 뒤의 앱이 돌려준 404는 error_page가 건드리지 않고 그대로 지나갑니다. 켜면 앱의 언어별 오류 화면이 국문 고정 페이지로 덮입니다. 그래서 앱이 응답할 수 있는 오류는 Next.js의 not-found와 error 경계가 맡고 Nginx는 앱에 닿지 못한 502, 503, 504에만 답하도록 나눴습니다. 그 경위와 locale 미들웨어 밖에 둔 요청은 LG Shield 페이지에 있습니다. Nginx: ngx_http_core_module
Nginx 없이 호스트에 Node를 설치하고 PM2로 next start를 데몬으로 돌리는 구성도 검토했습니다. 두 구성 모두 프로세스가 죽으면 다시 띄웁니다. PM2는 그 역할을 호스트의 프로세스 관리자가 맡고 Compose는 restart: unless-stopped가 맡습니다. 다른 점은 Node 버전과 시스템 패키지가 호스트에 설치되느냐 이미지 안에 들어가느냐이고 이 프로젝트는 컨테이너 쪽에 머물렀습니다.
로컬에서 같은 Compose로 확인합니다
macOS에서는 Docker Desktop 대신 Lima로 데몬을 띄웠습니다. limactl start template:docker로 인스턴스를 만들고 그 소켓을 DOCKER_HOST로 내보내면 호스트의 docker 명령이 그대로 동작합니다. Lima 1.x에서는 템플릿 주소에 template://처럼 슬래시가 붙었고 지금 문서는 template:docker 형식입니다. Lima: Templates
brew install lima docker docker-compose
limactl start template:docker
export DOCKER_HOST="$(limactl list docker --format 'unix://{{.Dir}}/sock/docker.sock')"
docker compose -f docker/compose.local.yml up --buildJenkins 컨테이너를 처음 올릴 때 docker-credential-desktop 실행 파일을 찾을 수 없다는 오류가 났습니다. Docker Desktop을 쓰던 ~/.docker/config.json에 credsStore: desktop이 남아 있어서였고 Docker 문서대로 이 값은 자격 증명 도우미 프로그램의 접미사라 Desktop이 없는 환경에서는 그 줄을 지우면 됩니다. Docker: docker login
로컬용 Compose는 운영과 두 가지가 다릅니다. 개발 서버를 띄우는 Dockerfile.dev를 쓰고 소스 폴더를 볼륨으로 마운트해 수정이 바로 반영되게 하되 node_modules는 익명 볼륨으로 컨테이너 안에 둡니다. 호스트의 macOS용 네이티브 모듈이 Linux 컨테이너로 들어가면 실행되지 않기 때문입니다. 같은 이유로 node-gyp가 필요한 패키지는 이미지의 Python 버전이 node-gyp 요구와 맞아야 설치되고 alpine 이미지에서는 musl 때문에 미리 빌드된 바이너리를 못 받는 경우가 있어 공식 예제와 같이 slim 이미지를 기본으로 둡니다.
확인 방법과 한계
배포 뒤에는 docker compose ps, curl -I로 받은 Server 헤더에 버전이 없는지, health check 경로가 리다이렉트 없이 200을 주는지 확인했습니다. 두 저장소에는 자동화 테스트가 없어 배포 자체의 성공 여부는 이 확인과 검수 티켓으로 대신했습니다.
이 방식의 한계도 분명합니다. down 뒤 up까지 서비스가 멈추는 무중단이 아닌 배포입니다. 프로젝트의 스크립트는 이전 이미지를 모두 지웠기 때문에 되돌리려면 이전 버전을 다시 빌드해 보내야 했고 위 예제는 태그 없는 레이어만 지우도록 바꿔 이전 태그로 up하면 돌아가게 했습니다. 레지스트리가 없어 어떤 이미지를 어디서 만들었는지 남는 기록은 로컬 셸 이력뿐입니다. Jenkins를 끝내 두지 않은 이유와 배포 소요 시간, 장애 횟수 같은 수치는 기록에 없어 적지 않았습니다. Dockerfile과 스크립트는 프로젝트 파일이 아니라 2026-10 시점의 공식 문서와 예제로 다시 쓴 것이라 옵션 이름은 글의 참고 자료 버전과 함께 읽어야 합니다.
참고 자료
- Next.js: output
- Next.js: How to use environment variables
- Next.js: Deploying
- Next.js with-docker 예제
- Docker: Multi-stage builds
- Docker: Multi-platform builds
- Docker: Build secrets
- Docker: docker image save
- Docker: docker image load
- Docker: docker login, Credential stores
- Compose: Set environment variables
- Compose: Interpolation
- Compose: version and name
- Compose: History and development
- Nginx: ngx_http_proxy_module
- Nginx: ngx_http_core_module
- Jenkins: Installing Jenkins with Docker
- Jenkins: Credentials Binding plugin
- Lima: Templates