이 글에서 다루는 내용

코드를 main 브랜치에 push하면 자동으로 서버에 배포되는 환경을 구성한다. 별도의 CI/CD 플랫폼 없이 GitHub Webhook과 자체 서버만으로 구현하는 방식이다.

전체 흐름은 다음과 같다.

GitHub Push (main 브랜치)
  → GitHub이 Webhook으로 POST 요청 전송
  → https://ytylab.com/webhook
  → nginx가 내부 서버(192.168.0.164:9000)로 프록시
  → Node.js 수신 서버에서 Secret 검증
  → deploy.sh 실행 (git pull → npm install → build → pm2 restart)

사전 준비

  • 도메인 및 HTTPS 인증서 (Let's Encrypt 등)
  • nginx 설치된 서버
  • Node.js, PM2 설치된 배포 대상 서버
  • GitHub 레포지토리

1. GitHub Webhook 설정

GitHub 레포지토리 페이지에서 아래 경로로 이동한다.

Settings → Webhooks → Add webhook
항목
Payload URL https://ytylab.com/webhook
Content type application/json
Secret 랜덤 문자열 (서버와 동일하게 설정)
Which events Just the push event

설정 후 GitHub 측에 초록 체크(✅)가 표시되면 연결 성공이다.
빨간 표시(❌)가 뜨면 서버가 응답하지 못한 것이므로 nginx 및 수신 서버 상태를 확인한다.


2. nginx 설정

외부에서 들어오는 /webhook 경로 요청을 내부 수신 서버로 프록시한다.

# /etc/nginx/sites-available/ytylab.com

location /webhook {
    proxy_pass http://192.168.0.164:9000;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
}

설정 적용:

sudo nginx -t
sudo systemctl reload nginx

3. Webhook 수신 서버 (Node.js)

내부 서버에서 9000 포트로 GitHub의 요청을 수신하고 검증한다.

// /var/www/webhook-server/index.js

const express = require('express')
const crypto  = require('crypto')
const { exec } = require('child_process')
const app = express()
app.use(express.json())

const SECRET = process.env.WEBHOOK_SECRET

app.post('/webhook', (req, res) => {
  // GitHub이 보낸 서명 추출
  const sig  = req.headers['x-hub-signature-256']
  const hash = 'sha256=' + crypto
    .createHmac('sha256', SECRET)
    .update(JSON.stringify(req.body))
    .digest('hex')

  // 서명 불일치 시 401 반환
  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(hash))) {
    return res.status(401).send('Unauthorized')
  }

  const repo   = req.body.repository?.name
  const branch = req.body.ref?.split('/').pop()

  // main 브랜치가 아니면 무시
  if (branch !== 'main') return res.send('skip')

  // 배포 스크립트 실행
  exec(`bash /var/www/deploy.sh ${repo}`, (err, out) => {
    if (err) return console.error(err)
    console.log(out)
  })

  res.send('ok')
})

app.listen(9000, () => console.log('webhook listening :9000'))

의존성 설치 및 실행

cd /var/www/webhook-server
npm install express
WEBHOOK_SECRET=your_secret_here node index.js

PM2로 상시 실행:

WEBHOOK_SECRET=your_secret_here pm2 start index.js --name webhook-server
pm2 save

4. 배포 스크립트 (deploy.sh)

레포명을 인자로 받아 해당 디렉토리에서 배포를 수행한다.

#!/bin/bash
# /var/www/deploy.sh

APP=$1

cd /var/www/$APP
git pull origin main
npm install
npm run build
pm2 restart $APP --update-env

echo "[$(date)] Deploy done: $APP"

실행 권한 부여:

chmod +x /var/www/deploy.sh

5. 동작 확인

GitHub Webhook 페이지의 Recent Deliveries 탭에서 마지막 요청의 성공/실패 여부와 응답 내용을 확인할 수 있다.

서버 로그 확인:

pm2 logs webhook-server

9000 포트 수신 확인:

ss -tlnp | grep 9000

이 방식의 단점

실제 운영 환경에서 이 방식을 장기적으로 사용하면 다음과 같은 한계에 부딪힌다.

네트워크 의존성 문제

  • 공유기 포트포워딩 구성 시 공인 IP가 변동되면 Webhook URL이 깨진다.
  • CGNAT 환경이나 ISP 방화벽에 의해 외부 접근 자체가 차단될 수 있다.

보안 취약점

  • 내부 서버 IP와 포트가 외부에 노출되는 구조다.
  • Secret 검증이 빠지거나 허술하면 임의의 외부 요청으로 배포가 트리거될 수 있다.

운영 불편함

  • 빌드 실패 시 롤백 메커니즘이 없다.
  • 배포 중 서비스가 일시 중단될 수 있다 (Zero-downtime 미지원).
  • 빌드 로그 관리가 어렵다.
  • 서버를 이전하거나 IP가 변경되면 전체 설정을 다시 해야 한다.

확장성 한계

  • 멀티 서버 배포, 컨테이너 기반 환경에는 적합하지 않다.
  • 테스트, 스테이징, 프로덕션 환경 분리가 어렵다.

최신 트렌드: 더 나은 대안들

GitHub Actions (가장 표준적인 방법)

별도 서버 없이 GitHub 인프라에서 빌드 및 배포까지 처리한다.

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: npm ci
      - run: npm run build
      - name: Deploy to server
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /var/www/my-app
            git pull
            pm2 restart my-app

GitHub Actions Self-hosted Runner

내부 서버에 Runner를 설치하면 서버가 GitHub에 아웃바운드로 연결하므로 포트 개방이 불필요하다. 사내 네트워크나 온프레미스 환경에 특히 적합하다.

# 서버에서 runner 설치 및 등록
./config.sh --url https://github.com/org/repo --token YOUR_TOKEN
./run.sh

Cloudflare Tunnel

포트포워딩 없이 내부 서버를 안전하게 외부에 노출한다. 공인 IP 변동과 무관하게 안정적으로 동작한다.

cloudflared tunnel --url http://localhost:9000

컨테이너 기반 배포 (현재 업계 표준)

Docker + GitHub Actions + 컨테이너 레지스트리를 조합하는 방식이 현재 가장 널리 사용된다.

Push → GitHub Actions → Docker 이미지 빌드
     → Container Registry (GHCR, ECR 등) Push
     → 서버에서 docker pull + 재시작

Zero-downtime 배포, 롤백, 환경 격리가 모두 자연스럽게 해결된다.


정리

방식 포트 개방 보안 확장성 난이도
Webhook + 포트포워딩 필요 보통 낮음 낮음
Self-hosted Runner 불필요 높음 보통 보통
Cloudflare Tunnel 불필요 높음 보통 낮음
GitHub Actions + SSH 불필요 높음 높음 보통
Docker + Actions 불필요 높음 매우 높음 높음

소규모 개인 프로젝트나 학습 목적이라면 이 글에서 소개한 Webhook 방식으로도 충분히 자동 배포를 경험할 수 있다. 하지만 팀 단위 협업이나 프로덕션 서비스라면 GitHub Actions 또는 Self-hosted Runner로의 전환을 권장한다.