THE DEVLOG

scribbly.

Next JS 치트 시트

2025.08.10 18:58:05

Next.js를 vercel이 아닌 VPS에서 배포하는 방법에 대해 정리하였다.

  1. Next.js의 Standalone의 특징
  2. Docker의 기초적인 문법
  3. Standalone 모드를 통해 dockerization
  4. Github Action을 통해 Push를 감지하고 VPS에서 docker를 빌드하고 실행하기

Standalone 모드

Output 설정 - 공식문서

Next.js의 config의 "output"에서 "standalone"이라는 옵션을 지원한다. 해당 옵션을 켜면 빌드 결과물에 standalone이라는 새로운 폴더가 생성된다.

  1. standalone은 프로젝트를 실행하기 위한 최소의 파일들을 가지고 있다.
  2. standalone폴더가 만들어지는 원리는 @vercel/nft(nft는 Node File Trace를 의미한다.)를 이용해 node.js 런타임 환경에 필요한 파일들을 추적할 수 있기 때문에다.
  3. 이후 next.js를 빌드한 이후, next.js를 실행하는데 필요한 (node_modules를 포함한) 최소 파일들을 standalone 폴더로 옮긴다.
  4. standalone 폴더는 이미 node_modules 폴더가 포함되어 있기 때문에, 해당 폴더를 실행함에 있어 npm install은 필요하지 않다.

이를 이용하여 아래와 같은 방식으로 docker 이미지를 만들게 된다.

  1. next build를 이용해 빌드를 한다.
  2. 빌드된 결과물 중 "standalone" 폴더만 도커 이미지로 복사한다.
  3. 도커 이미지에서 node .next/standalone/server.js를 이용해 next.js를 실행시킨다.

빌드 결과물 전체를 도커 이미지화 하는 방식에 비해 약 50% 가량 도커 이미지의 크기가 줄어들 수 있다.

단, standalone 모드를 사용함에 있어 유의할 점이 있는데, standalone 폴더에 public 폴더와 .next/static 폴더는 복사되지 않는다.
next.js가 작동하기 위해서는 public와 .next/static에 접근할 수 있어야 한다.
이를 해결하기 위한 두 가지 방법은

  1. next build를 실행한 후, 해당 폴더들을 CDN에 업로드하는 것이다. 각 CDN의 /_next/static, /_next/image, /public 경로로 업로드하면 된다.
  2. CDN을 쓰지 않는다면, 도커 이미지를 생성할 때에 이미지 안에 같이 복사해주면 된다.

여기서는 후자를 사용한다.

아래와 같이 standalone 빌드하고 테스트해볼 수 있다.

  1. 아래와 같이 nextConfig를 설정한다.

    /** @type {import('next').NextConfig} */
    const nextConfig = {
      output: "standalone",
    };
    
    module.exports = nextConfig;
    
  2. npm run build를 실행하고.next/standalone 폴더가 생성됐는지 확인한다.

    npm run build
    
  3. 만일 빌드된 결과물을 도커 이미지 없이 테스트하고 싶다면, 아래와 같이 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 기초문법

  1. FROM

    어떤 베이스 이미지에서 시작할지 지정

    FROM node:20-alpine
    
  2. WORKDIR

    작업 디렉토리를 지정 (이후 명령은 이 디렉토리에서 실행)

    WORKDIR /app
    
  3. COPY / ADD

    호스트 파일을 컨테이너에 복사
    ADD는 URL 다운로드나 압축해제 가능하지만, 대부분 COPY 권장

    COPY package*.json ./
    
  4. RUN

    빌드 시 실행할 명령(레이어 생성)

    RUN npm install
    
  5. ENV

    환경 변수를 설정

    ENV NODE_ENV=production
    
  6. EXPOSE

    문서화용 포트 지정 (실제 오픈은 docker run -p로)

    EXPOSE 3000
    
  7. CMD

    컨테이너가 시작될 때 실행할 기본 명령

    CMD ["node", "server.js"]
    
  8. ENTRYPOINT

    컨테이너의 고정 실행 파일 지정 (CMD나 docker run 인자가 뒤에 붙음)

    ENTRYPOINT ["python", "app.py"]
    
  9. USER

    컨테이너 실행 시 사용할 사용자 지정

    USER node
    
  10. 기본 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 기초문법

  1. 버전 선언

    • Compose 버전(3.x 이상 권장)
    version: "3.9"
    
  2. services 섹션

    • 실행할 컨테이너(서비스)들을 정의
    services:
      web:
        build: .
        ports:
          - "8000:8000"
      db:
        image: postgres:16-alpine
        environment:
          POSTGRES_PASSWORD: secret
    
  3. volumes / networks

    • 서비스 간 공유 볼륨이나 네트워크 설정
    volumes:
      db_data:
    networks:
      app_network:
    
  4. 전체 예시

    '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

  1. 먼저 기본 이미지는 node:20-alpine으로 한다. alpine는 경량화된 배포판이다. node.js 20 버전이다.

    FROM node:20-alpine AS base
    

빌드 단계

  1. 빌드 단계에서 사용될 이미지를 지정한다. 똑같이 node:20-alpine을 사용할 것이다.

    FROM base AS builder
    
  2. 작업 디렉토리를 지정한다.

    WORKDIR /app
    
  3. 지정한 작업 디렉토리에 pakage와 관련된 파일들을 복사한다.

    COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
    # 아래와 같이 복사 됨
    # /app/package.json
    # /app/package-lock.json
    
  4. 아래는 복사한 파일의 존재 여부(-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로 끝난다.

  5. next build를 실행할 때 필요한 파일들을 복사해준다.

    # 애플리케이션 소스 코드와 설정 파일들을 복사
    COPY src ./src
    COPY public ./public
    COPY next.config.js .
    COPY tsconfig.json .ghksrudqustn
    
  6. 환경변수를 받기 위한 값을 선언한다.(ARG), 그리고 해당 값을 도커 이미지의 환경 변수로 저장한다. (ENV)

    ARG ENV_VARIABLE
    ENV ENV_VARIABLE=${ENV_VARIABLE}
    ARG NEXT_PUBLIC_ENV_VARIABLE
    ENV NEXT_PUBLIC_ENV_VARIABLE=${NEXT_PUBLIC_ENV_VARIABLE}
    
  7. 패키지 매니저에 따라 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
    

실행단계

  1. 실행 단계에서 쓸 이미지는 역시 node.js 20의 alpine이다.

    FROM base AS runner
    WORKDIR /app
    
  2. 도커가 실행되는 리눅스 환경에 새로운 사용자를 생성해준다.

    RUN addgroup --system --gid 1001 nodejs
    RUN adduser --system --uid 1001 nextjs
    
  3. 이제부터 실행단계에서의 명령어는 nextjs라는 유저를 통해서 실행한다.

    USER nextjs
    
  4. 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
    
  5. 환경변수를 선언해준다.

    ARG ENV_VARIABLE
    ENV ENV_VARIABLE=${ENV_VARIABLE}
    ARG NEXT_PUBLIC_ENV_VARIABLE
    ENV NEXT_PUBLIC_ENV_VARIABLE=${NEXT_PUBLIC_ENV_VARIABLE}
    
  6. 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를 감지하고 이미지를 빌드하는 자동배포를 만드는 두 가지 방법은 아래와 같다.

  1. Webhook을 사용하여 GitHub에서 Push 이벤트가 발생됨을 감지한다. 이후 VPS의 서버에서 이미지를 빌드하고 실행한다.
  2. GitHub Actions를 사용하여 Runner 서버에서 이미지를 빌드한 후, 이를 Docker Hub와 같은 이미지 저장소에 업로드한다. 서버에서 해당 이미지를 다운로드 받아 실행한다.

2번의 방식이 현대적이지만, 이 예제에서는 쓰지 않는다.

1번의 방식도 정석적인 방법 중 하나이지만, Webhook을 설정한 후 VPS에 웹훅 서버를 만드는 작업이 번거롭다.

 

그래서 아래와 같이 간단하게 구현해보았다.

  1. GitHub Actions에서 Push를 감지하면, VPS 서버에 접속한다.
  2. VPS 서버에서 도커를 빌드하고 실행하는 명령어를 실행한다. 

1.00

이렇게하면 별도의 Webhook 없이도 VPS에서 실행할 수 있다.

VPS에서 이미지를 직접 빌드하고 실행하는 경우 장점이 있는데,

  1. VPS 서버의 성능에 따라 GitHub의 무료 Hosted Runner보다 더 빠른 빌드 성능을 얻을 수 있다. 참고로 내가 사용하는 VPS는 오라클 클라우드 플랫폼의 A1.4ocpu.24gbram 서버로, Hosted Runner보다 성능이 빠르다.
  2. 빌드 시에 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 : 빌드가 성공하면 실행한다.

 

현재 세팅은 빌드가 성공하면 빌드된 파일을 내렸다가 올리는 방식으로 중단되는 시간이 길지 않다.

만일 무중단 배포나 버전관리가 필요한 경우 아래의 게시글을 참고하면 좋다.

환경변수 설정하기

깃헙 액션을 사용하는 만큼, 환경 변수를 GitHub에서 관리하는 것이 좋다.

레포지토리/settings/environments으로 진입하여 "production"이라는 환경을 만들어주자.

Environment secrets : 콘솔에 출력될 때 일부가 마스킹처리되어 출력되는 환경변수들이다. secrets로 접근할 수 있다.

Environment variables : 콘솔에 그대로 출력되는 환경변수 들이다. vars로 접근할 수 있다.

Next.js 공식 레포 예제 - with-docker-compose에서는 ENV_VARIABLENEXT_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가 해당 환경변수를 읽을 수 있게 된다.

1.00