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