본문 바로가기
Nest.js

Nest.js CI/CD 파이프라인 구축 - GitHub Actions + Docker Hub + k3s

by 밍슈_ 2026. 4. 26.

GitHub에 코드를 push하면 자동으로 Docker 이미지를 빌드하고 k3s 서버에 배포되는 CI/CD 파이프라인을 구축을 해보았다.

Docker와 k3s를 사용 한다면 CI/CD 파이프라인을 구축하는게 좋다고 판단했기 때문. Docker를 사용하는 이유는 환경 세팅이 생각보다 굉장히 오래 걸린다. 설치와 테스트까지 한다면 말이다. 그래서 환경 통일과 환경 세팅 및 테스트를 수월하게 하기 위해 Docker를 사용한다. 운영 단계가 아니라서 개발 환경을 위해 아주아주 간단하게만 구성 했다.

 

빠른 개발을 위해 테스트 코드 테스트는 건너 뛰어서 CI/CD 구축을 했고 운영을 할 때는 테스트까지 통과해야 배포가 되고 빌드가 되게 할 생각이다.


전체 파이프라인

웹앱, 모바일 앱 이렇게 있기 때문에 dev와 main 브랜치 두개 다 하기를 원했다.

개발자 코드 push (dev/main 브랜치)
    ↓
GitHub Actions 자동 실행
    ↓
Docker 이미지 빌드 (pnpm + Node.js 24)
    ↓
Docker Hub에 이미지 push
    ↓
Tailscale로 서버 SSH 접속
    ↓
kubectl로 k3s 이미지 업데이트
    ↓
서버에 자동 배포 완료 🎉

브랜치별 배포 전략

dev 브랜치  → 개발 서버 (backend-dev k3s 네임스페이스)
main 브랜치 → 운영 서버 (backend-prod k3s 네임스페이스)

1단계: Dockerfile 작성

pnpm을 사용하는 Nest.js 프로젝트용 멀티 스테이지 빌드 Dockerfile

# 빌드 스테이지
FROM node:24-alpine AS builder

RUN npm install -g pnpm

WORKDIR /app

COPY package*.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile  # lock 파일 기반으로 정확한 버전 설치

COPY . .
RUN pnpm run build  # TypeScript → JavaScript 컴파일

# 실행 스테이지
FROM node:24-alpine AS runner

RUN npm install -g pnpm

WORKDIR /app

COPY package*.json pnpm-lock.yaml ./
RUN pnpm install --prod --frozen-lockfile  # 프로덕션 의존성만 설치

COPY --from=builder /app/dist ./dist  # 빌드 결과물만 복사

EXPOSE 3000

CMD ["node", "dist/main"]

멀티 스테이지 빌드를 사용하는 이유

단일 스테이지:
빌드 도구 + 소스코드 + 결과물 → 이미지 크기 크고 보안 취약

멀티 스테이지:
builder → 빌드만 담당
runner  → dist 결과물만 포함 → 이미지 크기 작고 보안 강화 ✅

2단계: .dockerignore 작성

Docker 이미지에 포함되면 안 되는 파일들을 지정

node_modules          # 컨테이너 내부에서 새로 설치
dist                  # 컨테이너 내부에서 새로 빌드
.env                  # 환경변수는 k3s Secret으로 관리
*.json                # Firebase 서비스 계정 키 등 제외
!package.json         # package.json은 포함
!nest-cli.json        # nest-cli.json은 포함
!tsconfig.json        # tsconfig.json은 포함
!tsconfig.build.json  # tsconfig.build.json은 포함
.git
README.md
test

3단계: GitHub Secrets 등록

환경변수 설정이다. GitHub 레포에 들어가서 세팅에 Secret으로 들어가면 설정할 수 있다.

민감한 정보는 GitHub Secrets에 저장(.env 파일이라고 생각하면 편하다)

Secret 이름설명

DOCKER_USERNAME Docker Hub 사용자명
DOCKER_TOKEN Docker Hub Access Token
SERVER_HOST 서버 Tailscale IP
SERVER_USER 서버 SSH 사용자명
SERVER_SSH_KEY 패스프레이즈 없는 SSH 개인키
TAILSCALE_AUTH_KEY Tailscale Ephemeral Auth Key
DB_URL_PROD 운영 DB 연결 URL
DB_URL_DEV 개발 DB 연결 URL

패스프레이즈 없는 SSH 키 생성

ssh-keygen -t ed25519 -C "github-actions" -f ~/.ssh/github_actions -N ""
ssh-copy-id -i ~/.ssh/github_actions.pub username@100.100.100.100
cat ~/.ssh/github_actions  # 이 내용을 SERVER_SSH_KEY에 등록

4단계: GitHub Actions Workflow 작성

.github/workflows/deploy.yml

name: Deploy to k3s

on:
  push:
    branches:
      - main
      - dev

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: 브랜치별 환경 설정
        run: |
          if [ "${{ github.ref_name }}" == "main" ]; then
            echo "NAMESPACE=backend-prod" >> $GITHUB_ENV
            echo "DEPLOYMENT=nestjs-prod" >> $GITHUB_ENV
            echo "IMAGE_TAG=latest" >> $GITHUB_ENV
          else
            echo "NAMESPACE=backend-dev" >> $GITHUB_ENV
            echo "DEPLOYMENT=nestjs-dev" >> $GITHUB_ENV
            echo "IMAGE_TAG=dev" >> $GITHUB_ENV
          fi

      - name: Docker Hub 로그인
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_TOKEN }}

      - name: Docker 이미지 빌드 및 푸시
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ secrets.DOCKER_USERNAME }}/hechyeomoyeo-backend:${{ env.IMAGE_TAG }}

      - name: Tailscale 연결
        uses: tailscale/github-action@v2
        with:
          authkey: ${{ secrets.TAILSCALE_AUTH_KEY }}

      - name: 서버에 배포
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: |
            kubectl set image deployment/${{ env.DEPLOYMENT }} \
              nestjs=${{ secrets.DOCKER_USERNAME }}/hechyeomoyeo-backend:${{ env.IMAGE_TAG }} \
              -n ${{ env.NAMESPACE }}
            kubectl rollout status deployment/${{ env.DEPLOYMENT }} -n ${{ env.NAMESPACE }}

Tailscale GitHub Actions를 사용하는 이유

GitHub Actions 서버는 외부 서버이므로 Tailscale 네트워크에 없다. 직접 SSH 접속이 불가능하기 때문에 GitHub Actions에 Tailscale을 설치해서 VPN으로 연결 한다.

GitHub Actions 서버 (외부)
    ↓ Tailscale Ephemeral 연결
서버 Tailscale 네트워크
    ↓ SSH
k3s 서버

5단계: k3s Deployment yaml 작성

# nestjs-dev.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nestjs-dev
  namespace: backend-dev
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nestjs-dev
  template:
    metadata:
      labels:
        app: nestjs-dev
    spec:
      containers:
      - name: nestjs
        image: gitusername/docker-image-name
        envFrom:
        - secretRef:
            name: nestjs-dev-secret  # 환경변수 Secret 참조
        ports:
        - containerPort: 3000
        volumeMounts:
        - name: firebase-key
          mountPath: /app/fire.json  # Firebase 키 파일 경로
          subPath: fire.json
      volumes:
      - name: fire
        secret:
          secretName: firebase-dev-secret  # Firebase 키 Secret
---
apiVersion: v1
kind: Service
metadata:
  name: nestjs-dev-service
  namespace: backend-dev
spec:
  selector:
    app: nestjs-dev
  ports:
  - port: 3000
    targetPort: 3000

6단계: Firebase 키 파일 Secret 처리

현재 Firebase에서 휴대폰인증과 FCM 푸시를 써야하기 때문에 필요하다.

Firebase 서비스 계정 키 같은 파일은 k3s Secret으로 관리

# 서버로 파일 전송
scp ./firebase-key.json username@100.100.100.100:~/

# Secret 생성
kubectl create secret generic firebase-dev-secret \
  --from-file=firebase-key.json=/home/susu/firebase-key.json \
  -n backend-dev

# 서버에서 파일 삭제 (보안)
rm /home/susu/firebase-key.json

환경변수 Secret 생성

kubectl create secret generic nestjs-dev-secret \
  --from-literal=ENV=dev \
  --from-literal=PORT=3000 \
  --from-literal=RESEND_API_KEY=값 \
  --from-literal=FIREBASE_SERVICE_ACCOUNT_PATH=/app/firebase-key.json \
  --from-literal=REFRESH_TOKEN_SECRET=값 \
  --from-literal=ACCESS_TOKEN_SECRET=값 \
  --from-literal=HASH_ROUNDS=10 \
  '--from-literal=DB_URL=postgresql://user:pass@postgres-dev-service.database.svc.cluster.local:5432/db' \
  -n backend-dev

k3s 내부 서비스 DNS 원리

k3s 내부에서 서비스끼리 통신할 때 다음 형식의 DNS를 사용합니다:

[서비스명].[네임스페이스].svc.cluster.local:[포트]

예시:
postgres-dev-service.database.svc.cluster.local:5432

이렇게 하면 IP가 바뀌어도 DNS로 안정적으로 연결할 수 있습니다.


배포 후 확인

# Pod 상태 확인
kubectl get pods -n backend-dev

# 로그 확인
kubectl logs -n backend-dev deployment/nestjs-dev

# 정상 출력 예시
[Nest] 1 - LOG [NestApplication] Nest application successfully started