Next.js를 vercel이 아닌 VPS에서 배포하는 방법에 대해 정리하였다.
- Next.js의 Standalone의 특징
- Docker의 기초적인 문법
- Standalone 모드를 통해 dockerization
- Github Action을 통해 Push를 감지하고 VPS에서 docker를 빌드하고 실행하기
- 참고할만한 게시글
Standalone 모드
Next.js의 config의 "output"에서 "standalone"이라는 옵션을 지원한다. 해당 옵션을 켜면 빌드 결과물에 standalone이라는 새로운 폴더가 생성된다.
- standalone은 프로젝트를 실행하기 위한 최소의 파일들을 가지고 있다.
- standalone폴더가 만들어지는 원리는
@vercel/nft
(nft는 Node File Trace를 의미한다.)를 이용해 node.js 런타임 환경에 필요한 파일들을 추적할 수 있기 때문에다. - 이후 next.js를 빌드한 이후, next.js를 실행하는데 필요한 (node_modules를 포함한) 최소 파일들을 standalone 폴더로 옮긴다.
- standalone 폴더는 이미 node_modules 폴더가 포함되어 있기 때문에, 해당 폴더를 실행함에 있어 npm install은 필요하지 않다.
이를 이용하여 아래와 같은 방식으로 docker 이미지를 만들게 된다.
- next build를 이용해 빌드를 한다.
- 빌드된 결과물 중 "standalone" 폴더만 도커 이미지로 복사한다.
- 도커 이미지에서
node .next/standalone/server.js
를 이용해 next.js를 실행시킨다.
빌드 결과물 전체를 도커 이미지화 하는 방식에 비해 약 50% 가량 도커 이미지의 크기가 줄어들 수 있다.
단, standalone 모드를 사용함에 있어 유의할 점이 있는데, standalone 폴더에 public 폴더와 .next/static 폴더는 복사되지 않는다.
next.js가 작동하기 위해서는 public와 .next/static에 접근할 수 있어야 한다.
이를 해결하기 위한 두 가지 방법은
- next build를 실행한 후, 해당 폴더들을 CDN에 업로드하는 것이다. 각 CDN의 /_next/static, /_next/image, /public 경로로 업로드하면 된다.
- CDN을 쓰지 않는다면, 도커 이미지를 생성할 때에 이미지 안에 같이 복사해주면 된다.
여기서는 후자를 사용한다.
아래와 같이 standalone 빌드하고 테스트해볼 수 있다.
-
아래와 같이 nextConfig를 설정한다.
/** @type {import('next').NextConfig} */ const nextConfig = { output: "standalone", }; module.exports = nextConfig;
-
npm run build를 실행하고
.next/standalone
폴더가 생성됐는지 확인한다.npm run build
-
만일 빌드된 결과물을 도커 이미지 없이 테스트하고 싶다면, 아래와 같이 static 폴더와 public 폴더를 standalone 폴더에 복사한 후 실행하여야 한다.
cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/ node .next/standalone/server.js
도커 기초 문법
가상머신
: 가상머신은 운영체제 위에 새로운 운영체제를 올려서 실행시키는 환경을 의미한다.
컨테이너
: 컨테이너는 가상머신과 다르게 호스트의 운영체제를 공유하면서, 어플리케이션을 실행하는데 필요한 파일들을 포함한 환경을 의미한다.
이미지
: 이미지는 컨테이너를 만드는 패키지이다.
도커
: 도커는 어플리케이션을 컨테이너 단위로 묶어서 배포할 수 있게 한다.
.dockerfile
: 도커 이미지를 생성하기 위한 확장자이다.
도커 컴포즈
: 여러 개의 도커 컨테이너를 관리하기 위한 도구이다. 여러 개의 컨테이너를 동시에 실행시킬 수 있다.
docker-compose.yml
: 도커 컴포즈를 위한 설정들을 담는 파일이다.
.dockerfile 기초문법
-
FROM
어떤 베이스 이미지에서 시작할지 지정
FROM node:20-alpine
-
WORKDIR
작업 디렉토리를 지정 (이후 명령은 이 디렉토리에서 실행)
WORKDIR /app
-
COPY / ADD
호스트 파일을 컨테이너에 복사
ADD
는 URL 다운로드나 압축해제 가능하지만, 대부분COPY
권장COPY package*.json ./
-
RUN
빌드 시 실행할 명령(레이어 생성)
RUN npm install
-
ENV
환경 변수를 설정
ENV NODE_ENV=production
-
EXPOSE
문서화용 포트 지정 (실제 오픈은
docker run -p
로)EXPOSE 3000
-
CMD
컨테이너가 시작될 때 실행할 기본 명령
CMD ["node", "server.js"]
-
ENTRYPOINT
컨테이너의 고정 실행 파일 지정 (CMD나
docker run
인자가 뒤에 붙음)ENTRYPOINT ["python", "app.py"]
-
USER
컨테이너 실행 시 사용할 사용자 지정
USER node
-
기본 Dockerfile 예시
FROM python:3.12-slim WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt COPY . . EXPOSE 8000 CMD ["python", "main.py"]
docker-compose 기초문법
-
버전 선언
- Compose 버전(3.x 이상 권장)
version: "3.9"
-
services 섹션
- 실행할 컨테이너(서비스)들을 정의
services: web: build: . ports: - "8000:8000" db: image: postgres:16-alpine environment: POSTGRES_PASSWORD: secret
-
volumes / networks
- 서비스 간 공유 볼륨이나 네트워크 설정
volumes: db_data: networks: app_network:
-
전체 예시
'app' 컨테이너와 'db' 컨테이너를 동시에 실행하는 예시
version: "3.9" services: app: build: . ports: - "8080:3000" env_file: - .env depends_on: - db db: image: postgres:16-alpine environment: POSTGRES_USER: app POSTGRES_PASSWORD: secret POSTGRES_DB: appdb volumes: - db_data:/var/lib/postgresql/data volumes: db_data:
Next.js Dockerization
Standalone 모드로 만들어진 파일을 통해 도커 이미지를 빌드하는 .Dockerfile을 작성해본다. Next.js 공식 레포 예제 - with-docker-compose
-
먼저 기본 이미지는 node:20-alpine으로 한다. alpine는 경량화된 배포판이다. node.js 20 버전이다.
FROM node:20-alpine AS base
빌드 단계
-
빌드 단계에서 사용될 이미지를 지정한다. 똑같이 node:20-alpine을 사용할 것이다.
FROM base AS builder
-
작업 디렉토리를 지정한다.
WORKDIR /app
-
지정한 작업 디렉토리에 pakage와 관련된 파일들을 복사한다.
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ # 아래와 같이 복사 됨 # /app/package.json # /app/package-lock.json
-
아래는 복사한 파일의 존재 여부(
-f
)에 따라서 적절한 명령어(yarn
,npm ci
,pnpm i
를 실행하는 코드다.RUN \ if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ elif [ -f package-lock.json ]; then npm ci; \ elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i; \ # lockfile이 없어도 설치가 가능하도록 fallback 설정 else echo "Warning: Lockfile not found. It is recommended to commit lockfiles to version control." && yarn install; \ fi
참고로 sh 쉘에서 if문은 if로 시작하고 fi로 끝난다.
-
next build
를 실행할 때 필요한 파일들을 복사해준다.# 애플리케이션 소스 코드와 설정 파일들을 복사 COPY src ./src COPY public ./public COPY next.config.js . COPY tsconfig.json .ghksrudqustn
-
환경변수를 받기 위한 값을 선언한다.(
ARG
), 그리고 해당 값을 도커 이미지의 환경 변수로 저장한다. (ENV
)ARG ENV_VARIABLE ENV ENV_VARIABLE=${ENV_VARIABLE} ARG NEXT_PUBLIC_ENV_VARIABLE ENV NEXT_PUBLIC_ENV_VARIABLE=${NEXT_PUBLIC_ENV_VARIABLE}
-
패키지 매니저에 따라 next build 명령어를 실행해준다.
RUN \ if [ -f yarn.lock ]; then yarn build; \ elif [ -f package-lock.json ]; then npm run build; \ elif [ -f pnpm-lock.yaml ]; then pnpm build; \ else npm run build; \ fi
실행단계
-
실행 단계에서 쓸 이미지는 역시 node.js 20의
alpine
이다.FROM base AS runner WORKDIR /app
-
도커가 실행되는 리눅스 환경에 새로운 사용자를 생성해준다.
RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs
-
이제부터 실행단계에서의 명령어는 nextjs라는 유저를 통해서 실행한다.
USER nextjs
-
standalone 폴더와 함께, 퍼블릭 폴더, static 폴더를 복사해준다.
# --chown=nextjs:nodejs: 파일의 소유권을 nextjs 사용자와 nodejs 그룹으로 설정 COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
-
환경변수를 선언해준다.
ARG ENV_VARIABLE ENV ENV_VARIABLE=${ENV_VARIABLE} ARG NEXT_PUBLIC_ENV_VARIABLE ENV NEXT_PUBLIC_ENV_VARIABLE=${NEXT_PUBLIC_ENV_VARIABLE}
-
node server.js
를 통해 복사된 server.js 파일을 실행하면 된다. 이때 CMD 명령어를 사용한다.CMD ["node", "server.js"]
Docker Compose
도커 컴포즈는 앞서 만든 도커파일을 실행하기 위해 필요한 arg인 env 파일, 그리고 포트 번호가 필요하다.
추가로 networks를 지정해줬는데, 엔진엑스 프록시 매니저 등를 통해서 맵핑할 때 사용한다.
services:
next-app: # Next.js 애플리케이션 서비스 정의
container_name: next-app # 컨테이너 이름을 'next-app'으로 지정
build: # Docker 이미지 빌드 설정
context: . # 빌드 컨텍스트: 현재 디렉토리 (.)를 사용
dockerfile: prod.Dockerfile # 사용할 Dockerfile 지정
args: # 빌드 시 전달할 환경변수들
ENV_VARIABLE: ${ENV_VARIABLE} # 서버 사이드에서만 사용되는 환경변수
NEXT_PUBLIC_ENV_VARIABLE: ${NEXT_PUBLIC_ENV_VARIABLE} # 클라이언트 사이드에서도 사용되는 환경변수 (브라우저에서 접근 가능)
restart: always # 컨테이너가 종료되면 자동으로 재시작
ports: # 포트 매핑: 호스트의 3000번 포트를 컨테이너의 3000번 포트와 연결
- 3000:3000
networks: # 컨테이너가 사용할 네트워크 지정
- npm-network
networks: # 네트워크 정의: 컨테이너들이 서로 통신할 수 있도록 네트워크를 생성합니다
npm-network: # 컨테이너 이름을 호스트명으로 사용하여 서로 통신할 수 있습니다
external: true # 외부에서 생성된 네트워크를 사용 (docker network create npm-network로 미리 생성 필요)
도커 띄우기
각각의 dockerfile과 yaml을 prod.Dockerfile
, compose.prod.yaml
로 저장한 후 아래와 같이 실행할 수 있다. (파일명을 지정하기 때문에 -f
지시자를 붙여서 실행)
docker compose -f compose.prod.yaml build # 도커 컴포즈로 빌드
docker network create npm-network # 네트워크가 없는 경우 네크워크 생성
docker compose -f compose.prod.yaml up -d # 컨테이너 실행
docker compose -f compose.prod.yaml ps # 실행상태 확인
docker compose -f compose.prod.yaml logs next-app # 로그 확인
docker compose -f compose.prod.yaml down # 실행중인 도커를 중단시킬 때
3000번 포트로 열었기에 localhost:3000을 통해 접속이 가능한 상태가 된다.
GitHub의 Push를 감지하고 도커 이미지 빌드 및 실행
GitHub의 Push를 감지하고 이미지를 빌드하는 자동배포를 만드는 두 가지 방법은 아래와 같다.
- Webhook을 사용하여 GitHub에서 Push 이벤트가 발생됨을 감지한다. 이후 VPS의 서버에서 이미지를 빌드하고 실행한다.
- GitHub Actions를 사용하여 Runner 서버에서 이미지를 빌드한 후, 이를 Docker Hub와 같은 이미지 저장소에 업로드한다. 서버에서 해당 이미지를 다운로드 받아 실행한다.
2번의 방식이 현대적이지만, 이 예제에서는 쓰지 않는다.
1번의 방식도 정석적인 방법 중 하나이지만, Webhook을 설정한 후 VPS에 웹훅 서버를 만드는 작업이 번거롭다.
그래서 아래와 같이 간단하게 구현해보았다.
- GitHub Actions에서 Push를 감지하면, VPS 서버에 접속한다.
- VPS 서버에서 도커를 빌드하고 실행하는 명령어를 실행한다.
이렇게하면 별도의 Webhook 없이도 VPS에서 실행할 수 있다.
VPS에서 이미지를 직접 빌드하고 실행하는 경우 장점이 있는데,
- VPS 서버의 성능에 따라 GitHub의 무료 Hosted Runner보다 더 빠른 빌드 성능을 얻을 수 있다. 참고로 내가 사용하는 VPS는 오라클 클라우드 플랫폼의 A1.4ocpu.24gbram 서버로, Hosted Runner보다 성능이 빠르다.
- 빌드 시에 VPS의 npm에서 캐싱된 모듈이 있다면 이를 그대로 사용한다.
.github/workflows/deploy.yml
에 아래와 같이 yml 파일을 작성해주었다.
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
set -e
trap 'echo "Deploy failed at line $LINENO"' ERR
ssh-keyscan -H github.com >> ~/.ssh/known_hosts
REPO_DIR=~/${{ github.event.repository.name }}
[ -d "$REPO_DIR/.git" ] || git clone --depth 1 https://github.com/${{ github.repository }}.git "$REPO_DIR"
cd "$REPO_DIR"
git fetch --prune --depth=1 origin main
git reset --hard origin/main
docker compose -f compose.prod.yaml build --pull
docker compose -f compose.prod.yaml up -d --remove-orphans
appleboy/ssh-action은 원격으로 접속하여 쉘 명령을 입력하는 액션으로 host, username, ssh_key를 파라미터로 받는다.
스크립트는 아래와 같이 작성하였다.
set -e
: 도중 에러가 발생하면 즉시 멈춘다. 가령 빌드 중 오류가 발생하면 즉시 중단.
trap 'echo "Deploy failed at line $LINENO"' ERR
: 에러가 발생하면 에러가 발생한 지점을 알려준다.
ssh-keyscan -H github.com >> ~/.ssh/known_hosts
: VPS 서버에서 깃허브에 접속할 수 있는 권한을 지정한다.
[ -d "$REPO_DIR/.git" ] || git clone --depth 1 https://github.com/${{ github.repository }}.git "$REPO_DIR"
: 액션이 실행되는 레포 이름을 바탕으로 "레포이름/.git"이 있는지 확인한다. 만일 해당 폴더가 없으면 최신 커밋(--depth 1
)을 클론한다.
cd "$REPO_DIR"
: 해당 폴더로 이동한다.
git fetch --depth=1 origin main
: 최신 커밋 하나를 가져온다
git reset --hard origin/main
: 최신 커밋으로 이동한다.
docker compose -f compose.prod.yaml build --pull
: 도커를 빌드한다.
docker compose -f compose.prod.yaml up -d --remove-orphans
: 빌드가 성공하면 실행한다.
현재 세팅은 빌드가 성공하면 빌드된 파일을 내렸다가 올리는 방식으로 중단되는 시간이 길지 않다.
만일 무중단 배포나 버전관리가 필요한 경우 아래의 게시글을 참고하면 좋다.
- Vercel to Self-Hosted Next.js: Setting up Zero Downtime Deploys with GitHub Actions - Graeme Fulton :
환경변수 설정하기
깃헙 액션을 사용하는 만큼, 환경 변수를 GitHub에서 관리하는 것이 좋다.
레포지토리/settings/environments으로 진입하여 "production"이라는 환경을 만들어주자.
Environment secrets
: 콘솔에 출력될 때 일부가 마스킹처리되어 출력되는 환경변수들이다. secrets로 접근할 수 있다.
Environment variables
: 콘솔에 그대로 출력되는 환경변수 들이다. vars로 접근할 수 있다.
Next.js 공식 레포 예제 - with-docker-compose에서는 ENV_VARIABLE
과 NEXT_PUBLIC_ENV_VARIABLE
이라는 환경 변수를 사용하고 있다. ENV는 secrets으로, 넥스트 퍼블릭은 variables로 설정한다.
GitHub Actions의 YML파일은 아래와 같이 수정하면 된다.
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: appleboy/ssh-action@v1.2.0
env:
ENV_VARIABLE: ${{ secrets.ENV_VARIABLE }} # secrets로부터 환경변수 가져와서 할당
NEXT_PUBLIC_ENV_VARIABLE: ${{ vars.NEXT_PUBLIC_ENV_VARIABLE }} # vars로부터 환경변수 가져와서 할당
with:
envs: ENV_VARIABLE,NEXT_PUBLIC_ENV_VARIABLE # 할당된 환경변수 이름들을 전달
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
set -e
trap 'echo "Deploy failed at line $LINENO"' ERR
ssh-keyscan -H github.com >> ~/.ssh/known_hosts
REPO_DIR=~/${{ github.event.repository.name }}
[ -d "$REPO_DIR/.git" ] || git clone --depth 1 https://github.com/${{ github.repository }}.git "$REPO_DIR"
cd "$REPO_DIR"
git fetch --prune --depth=1 origin main
git reset --hard origin/main
docker compose -f compose.prod.yaml build --pull
docker compose -f compose.prod.yaml up -d --remove-orphans
이렇게하면 docker compose가 해당 환경변수를 읽을 수 있게 된다.