Jenkins 파이프라인으로 GitHub push 시 Next.js·Spring Boot 자동 빌드·배포 구성하는 전 과정 실제 삽질 기반으로 정리했어요.
이전 글: [서버, 인프라] - Next.js + Spring Boot, Hetzner 서버에 직접 올려봤습니다 — Docker Compose + Nginx 세팅 4편
4편에서 Docker Compose로 프론트/백엔드 올리는 것까지 했잖아요.
근데 솔직히 말하면, 코드 바꿀 때마다 서버 들어가서 직접 빌드하고 재시작하는 게 너무 귀찮거든요. 파일 하나 고쳤는데 SSH 접속 → git pull → docker build → docker compose restart 이 과정을 매번 반복해야 해요.
이게 Jenkins CI/CD를 쓰는 이유예요. GitHub에 push 하면 알아서 빌드하고 배포까지 해주거든요.
전체 흐름 먼저 잡고 시작할게요
GitHub push
→ Webhook으로 Jenkins에 알림
→ Jenkins가 코드 pull
→ Docker 이미지 빌드
→ 기존 컨테이너 교체
→ 배포 완료
AWS 쓸 때는 ECR에 이미지 올리고 EC2에 SSH로 배포하는 복잡한 구조였는데, Hetzner에서는 Jenkins가 같은 서버에 있으니까 훨씬 단순해요.
1단계 — Jenkins 컨테이너에 Docker 소켓 마운트
여기서 잠깐. 중요한 포인트예요.
Jenkins가 컨테이너로 떠있거든요. 컨테이너 안에서 docker build 명령어를 쓰려면 호스트의 Docker 소켓을 Jenkins 컨테이너 안으로 연결해줘야 해요.
안 하면 Jenkins 파이프라인에서 docker 명령어 칠 때마다 Permission denied 나와요.
Jenkins 컨테이너 재실행이 필요해요.
docker stop jenkins
docker rm jenkins
docker run -d \
--name jenkins \
--restart unless-stopped \
-p 8090:8080 \
-p 50000:50000 \
-v /opt/jenkins_home:/var/jenkins_home \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /usr/bin/docker:/usr/bin/docker \
-v /opt/app:/opt/app \
-e JAVA_TOOL_OPTIONS="-Duser.timezone=Asia/Seoul" \
jenkins/jenkins:lts
추가된 게 세 가지예요.
/var/run/docker.sock — 호스트 Docker 소켓 마운트 /usr/bin/docker — docker 바이너리 마운트 /opt/app — Docker Compose 파일 접근용
Docker 소켓 권한도 열어줘야 해요
sudo chmod 666 /var/run/docker.sock
재부팅해도 유지되게 udev 규칙도 추가해요.
cat > /etc/udev/rules.d/99-docker.rules << 'EOF'
KERNEL=="docker.sock", GROUP="docker", MODE="0666"
EOF
2단계 — Jenkins 컨테이너에 SSH 키 등록
Jenkins가 GitHub Private 레포에서 코드를 pull 해와야 하거든요. SSH 키가 Jenkins 컨테이너 안에 있어야 해요.
호스트 서버의 SSH 키를 Jenkins 컨테이너 안으로 복사해줄게요.
docker exec jenkins mkdir -p /var/jenkins_home/.ssh
docker cp ~/.ssh/id_ed25519 jenkins:/var/jenkins_home/.ssh/id_ed25519
docker cp ~/.ssh/id_ed25519.pub jenkins:/var/jenkins_home/.ssh/id_ed25519.pub
chmod 600 /opt/jenkins_home/.ssh/id_ed25519
chown -R 1000:1000 /opt/jenkins_home/.ssh
chmod랑 chown은 컨테이너 안에서 하면 권한 문제가 생겨요. 호스트에서 /opt/jenkins_home 경로로 직접 해줘야 해요. 이게 포인트예요.
연결 테스트
docker exec -it jenkins ssh -T git@github.com
Hi 계정명! You've successfully authenticated 나오면 성공이에요.
처음엔 Are you sure you want to continue connecting? 나오는데 yes 입력하면 돼요.

3단계 — Jenkinsfile 작성
기존 Jenkinsfile이 AWS ECR + EC2 기반으로 돼있었어요. Hetzner 로컬 빌드 방식으로 완전히 새로 작성했어요.
프론트엔드 Jenkinsfile
pipeline {
agent any
environment {
IMAGE_NAME = 'front-app'
CONTAINER_NAME = 'front-app'
APP_DIR = '/opt/app'
}
stages {
stage('Pull') {
steps {
git branch: 'main',
credentialsId: 'github-credentials',
url: 'git@github.com:계정명/프론트레포.git'
}
}
stage('Build Image') {
steps {
sh """
docker build -t ${IMAGE_NAME}:latest .
"""
}
}
stage('Deploy') {
steps {
sh """
cd ${APP_DIR}
docker-compose stop ${CONTAINER_NAME}
docker-compose rm -f ${CONTAINER_NAME}
docker-compose up -d ${CONTAINER_NAME}
"""
}
}
stage('Clean up') {
steps {
sh """
docker image prune -f
"""
}
}
}
post {
success {
echo '프론트엔드 배포 완료'
}
failure {
echo '프론트엔드 배포 실패'
}
}
}
백엔드 Jenkinsfile
백엔드는 Gradle 빌드가 추가되고, 배포 후 Nginx 재시작이 필요해요.
왜냐면 백엔드 컨테이너가 교체되면 IP가 바뀌는데, Nginx가 예전 IP를 캐싱하고 있어서 502 에러가 나거든요. 배포 마지막에 Nginx 재시작을 넣어줘야 해요.
pipeline {
agent any
environment {
IMAGE_NAME = 'api-app'
CONTAINER_NAME = 'api-app'
APP_DIR = '/opt/app'
}
stages {
stage('Pull') {
steps {
git branch: 'master',
credentialsId: 'github-credentials',
url: 'git@github.com:계정명/백엔드레포.git'
}
}
stage('Gradle Build') {
steps {
dir('api') {
sh """
chmod +x gradlew
./gradlew clean build -x test
"""
}
}
}
stage('Build Image') {
steps {
dir('api') {
sh """
docker build -t ${IMAGE_NAME}:latest .
"""
}
}
}
stage('Deploy') {
steps {
sh """
cd ${APP_DIR}
docker-compose stop ${CONTAINER_NAME}
docker-compose rm -f ${CONTAINER_NAME}
docker-compose up -d ${CONTAINER_NAME}
docker-compose restart nginx
"""
}
}
stage('Clean up') {
steps {
sh """
docker image prune -f
"""
}
}
}
post {
success {
echo '백엔드 배포 완료'
}
failure {
echo '백엔드 배포 실패'
}
}
}
AWS 방식이랑 비교하면 엄청 단순해졌어요. ECR 로그인, 이미지 push, EC2 SSH 접속 이런 과정이 다 사라졌거든요. Jenkins가 같은 서버에 있으니까 바로 빌드하고 바로 배포하면 끝이에요.
4단계 — Jenkins 파이프라인 아이템 생성
Dashboard → New Item
- Item name: front-pipeline
- Pipeline 선택 → OK
설정 화면에서
General
- GitHub project 체크
- Project url: GitHub 레포 URL
Build Triggers
- GitHub hook trigger for GITScm polling 체크
Pipeline
- Definition: Pipeline script from SCM
- SCM: Git
- Repository URL: git@github.com:계정명/레포명.git
- Credentials: github-credentials
- Branch: */main
- Script Path: Jenkinsfile
Save 누르면 돼요. 백엔드도 동일하게 만들면 돼요.
5단계 — GitHub Webhook 설정
Webhook이 없으면 Jenkins가 GitHub push를 감지 못해요. 수동으로 Build Now 눌러야 하거든요.
GitHub 레포 → Settings → Webhooks → Add webhook
- Payload URL: http://서버IP:8090/github-webhook/
- Content type: application/json
- Which events: Just the push event
- Active 체크
프론트/백엔드 레포 각각 등록해야 해요.
등록하고 나서 초록색 체크 뜨면 연결 성공이에요.

삽질 포인트 정리
1. Docker 소켓 권한
chmod 666 /var/run/docker.sock 안 하면 Jenkins에서 docker 명령어 칠 때마다 permission denied 나와요. 서버 재부팅하면 날아가니까 udev 규칙으로 영구 적용해두는 게 좋아요.
2. SSH 키 권한
chmod 600이랑 chown 1000:1000은 컨테이너 안에서 하면 안 돼요. 호스트에서 /opt/jenkins_home/.ssh 경로로 직접 해줘야 해요.
3. APP_DIR 경로
Jenkins 컨테이너에서 /root/app은 접근이 안 돼요. /opt/app으로 옮기고 마운트 해줘야 해요.
4. 백엔드 배포 후 Nginx 502 에러
백엔드 컨테이너가 교체되면 내부 IP가 바뀌는데 Nginx가 예전 IP를 캐싱하고 있어서 502가 나와요. Jenkinsfile Deploy 단계 마지막에 docker-compose restart nginx 넣어두면 자동으로 해결돼요.
5. docker compose vs docker-compose
Jenkins 컨테이너 안에서 docker compose(신버전 플러그인 방식)는 동작 안 할 수 있어요. docker-compose(구버전) 설치해서 쓰는 게 안정적이에요.
docker exec -u root jenkins bash -c "curl -L https://github.com/docker/compose/releases/latest/download/docker-compose-linux-x86_64 -o /usr/local/bin/docker-compose && chmod +x /usr/local/bin/docker-compose"
최종 구성 완성
GitHub push
↓
Webhook (8090/github-webhook/)
↓
Jenkins 파이프라인 자동 트리거
↓
코드 pull → 빌드 → 컨테이너 교체 → Nginx 재시작
↓
서비스 자동 반영
결국 핵심은 Jenkins가 컨테이너로 떠있을 때 Docker 소켓이랑 SSH 키 두 가지를 제대로 연결해주는 거예요. 이게 안 되면 파이프라인이 아무리 잘 짜여있어도 소용없거든요.
다음 편에서는 도메인 연결이랑 HTTPS 설정까지 다뤄볼게요.