🥳 React 기반 정적 웹 애플리케이션을 Bastion 서버를 거쳐 Private Subnet에 위치한 Web 서버에 자동 배포하는 CI/CD 파이프라인을 구축했습니다.
1. Web 서버
2. Bastion 서버 설정
3. GitHub Secrets 설정
| 키 | 설명 |
|---|---|
BASTION_USER | Bastion 서버 SSH 사용자명 (예: ubuntu) |
BASTION_HOST | Bastion 서버의 퍼블릭 IP 주소 (예: 210.xxx.xxx.xxx) |
BASTION_KEY | Bastion 서버에 접속하기 위한 SSH Private Key |
WEB_SERVER | 내부 Web 서버의 프라이빗 IP (포트포워딩 대상) |
APP_URL | 배포 완료 후 접근 가능한 서비스 URL |
REACT_DEVELOP_JSON | .env 파일에 들어갈 환경 변수 JSON |
SLACK_WEBHOOK_URL | 배포 결과를 전송할 Slack Webhook URL |
1. .env 생성 자동화
echo '${{ secrets.REACT_DEVELOP_JSON }}' | jq -r 'to_entries[] | "\(.key)=\(.value)"' > .env
echo "REACT_APP_VERSION=${{ env.DEPLOY_VERSION }}" >> .env
2. Bastion → Web 서버 파일 복사
- name: Deploy to Web Server via Bastion
run: |
rsync -avz --delete --progress \
-e "ssh -i private_key -p 10000" \
$APP_DIR/ ${{ env.BASTION_USER }}@${{ env.BASTION_HOST }}:${{ env.DEPLOY_DIR }}/
3. 실패 시 자동 롤백
if [ -f /tmp/latest_backup ]; then
cp -r $LATEST_BACKUP/* $WEB_DIR/
systemctl restart nginx
name: Deploy React App
on:
push:
branches:
- develop
workflow_dispatch:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm run test
env:
DISABLE_ESLINT_PLUGIN: true
CI: true
build-and-deploy:
needs: validate
runs-on: ubuntu-latest
timeout-minutes: 30
env:
APP_DIR: build
BASTION_HOST: ${{ secrets.BASTION_HOST }}
BASTION_USER: ${{ secrets.BASTION_USER }}
BASTION_KEY: ${{ secrets.BASTION_KEY }}
WEB_SERVER: ${{ secrets.WEB_SERVER }}
WEB_DIR: /var/www/html
WEB_BACKUP_DIR: /var/www/backups
DEPLOY_DIR: /home/ubuntu/deploy
NODE_ENV: development
DEPLOY_VERSION: ${{ github.sha }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Cache dependencies
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: npm ci
- name: Generate .env from REACT_DEVELOP_JSON
run: |
echo '${{ secrets.REACT_DEVELOP_JSON }}' | jq -r 'to_entries[] | "\(.key)=\(.value)"' > .env
echo "REACT_APP_VERSION=${{ env.DEPLOY_VERSION }}" >> .env
echo "REACT_APP_BUILD_TIME=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> .env
echo ".env 파일 생성 완료"
echo "생성된 환경 변수 목록:"
cat .env | sed 's/=.*/=***/' | sed 's/^/ - /'
- name: Build app
run: npm run build
env:
DISABLE_ESLINT_PLUGIN: true
GENERATE_SOURCEMAP: false
- name: Create version file
run: |
cat <<EOF > $APP_DIR/version.json
{
"version": "${{ env.DEPLOY_VERSION }}",
"deployedAt": "$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
}
EOF
- name: Save SSH private key
run: |
echo "${{ env.BASTION_KEY }}" > private_key
chmod 600 private_key
- name: Add known hosts
run: |
mkdir -p ~/.ssh
ssh-keyscan -H -p 22 ${{ env.BASTION_HOST }} >> ~/.ssh/known_hosts
ssh-keyscan -H -p 10000 ${{ env.BASTION_HOST }} >> ~/.ssh/known_hosts
- name: Test SSH connection to Bastion
run: ssh -i private_key -o ConnectTimeout=10 ${{ env.BASTION_USER }}@${{ env.BASTION_HOST }} 'echo "Bastion SSH connection successful"'
- name: Test SSH connection to Web Server via Bastion
run: ssh -i private_key -o ConnectTimeout=10 -p 10000 ${{ env.BASTION_USER }}@${{ env.BASTION_HOST }} 'echo "Web Server SSH connection successful"'
- name: Prepare deployment directory
run: ssh -i private_key -p 10000 ${{ env.BASTION_USER }}@${{ env.BASTION_HOST }} '
sudo mkdir -p ${{ env.DEPLOY_DIR }} &&
sudo chown ubuntu:ubuntu ${{ env.DEPLOY_DIR }} &&
sudo chmod 755 ${{ env.DEPLOY_DIR }} &&
rm -rf ${{ env.DEPLOY_DIR }}/*
'
- name: Deploy to Web Server via Bastion
run: |
rsync -avz --delete --progress \
-e "ssh -i private_key -p 10000" \
$APP_DIR/ ${{ env.BASTION_USER }}@${{ env.BASTION_HOST }}:${{ env.DEPLOY_DIR }}/
ssh -i private_key -p 10000 ${{ env.BASTION_USER }}@${{ env.BASTION_HOST }} '
echo "배포 시작..."
sudo mkdir -p ${{ env.WEB_BACKUP_DIR }}
BACKUP_DIR=${{ env.WEB_BACKUP_DIR }}/backup_$(date +%Y%m%d_%H%M%S)
sudo mkdir -p $BACKUP_DIR
sudo cp -r ${{ env.WEB_DIR }}/* $BACKUP_DIR/ 2>/dev/null || true
echo $BACKUP_DIR > /tmp/latest_backup
echo "백업 완료: $BACKUP_DIR"
if [ -d "${{ env.DEPLOY_DIR }}" ] && [ "$(ls -A ${{ env.DEPLOY_DIR }})" ]; then
sudo rm -rf ${{ env.WEB_DIR }}/*
sudo cp -r ${{ env.DEPLOY_DIR }}/* ${{ env.WEB_DIR }}/
sudo chown -R www-data:www-data ${{ env.WEB_DIR }}
sudo rm -rf ${{ env.DEPLOY_DIR }}/*
echo "배포 완료"
else
echo "배포할 파일이 없습니다"
exit 1
fi
'
- name: Restart Nginx
run: ssh -i private_key -p 10000 ${{ env.BASTION_USER }}@${{ env.BASTION_HOST }} 'sudo systemctl restart nginx'
- name: Verify Nginx status
run: |
ssh -i private_key -p 10000 ${{ env.BASTION_USER }}@${{ env.BASTION_HOST }} 'sudo systemctl is-active nginx || (echo "Nginx is not running" && exit 1)'
ssh -i private_key -p 10000 ${{ env.BASTION_USER }}@${{ env.BASTION_HOST }} 'sudo nginx -t || (echo "Nginx configuration test failed" && exit 1)'
- name: Health check
run: |
for i in {1..10}; do
if ssh -i private_key -p 10000 ${{ env.BASTION_USER }}@${{ env.BASTION_HOST }} 'curl -f http://localhost/'; then
echo "Health check passed"
break
fi
if [ $i -eq 10 ]; then
echo "Health check failed after 10 attempts"
exit 1
fi
echo "Attempt $i: Health check failed, retrying in 10 seconds..."
sleep 10
done
- name: External health check
if: success()
run: |
if [ ! -z "${{ secrets.APP_URL }}" ]; then
for i in {1..5}; do
if curl -f ${{ secrets.APP_URL }}/; then
echo "External health check passed"
break
fi
if [ $i -eq 5 ]; then
echo "External health check failed after 5 attempts"
echo "::warning::External health check failed, but deployment completed"
fi
echo "External attempt $i: Health check failed, retrying in 10 seconds..."
sleep 10
done
else
echo "APP_URL not configured, skipping external health check"
fi
- name: Rollback on failure
if: failure()
run: |
echo "배포 실패로 인한 롤백 시작..."
ssh -i private_key -p 10000 ${{ env.BASTION_USER }}@${{ env.BASTION_HOST }} '
if [ -f /tmp/latest_backup ]; then
LATEST_BACKUP=$(cat /tmp/latest_backup)
if [ -d "$LATEST_BACKUP" ]; then
sudo rm -rf ${{ env.WEB_DIR }}/*
sudo cp -r $LATEST_BACKUP/* ${{ env.WEB_DIR }}/
sudo chown -R www-data:www-data ${{ env.WEB_DIR }}
sudo systemctl restart nginx
echo "롤백 완료: $LATEST_BACKUP"
else
echo "백업 디렉토리를 찾을 수 없습니다: $LATEST_BACKUP"
fi
else
echo "백업 정보를 찾을 수 없습니다"
fi
'
- name: Clean old backups
if: success()
run: ssh -i private_key -p 10000 ${{ env.BASTION_USER }}@${{ env.BASTION_HOST }} "sudo find ${{ env.WEB_BACKUP_DIR }}/backup_* -maxdepth 0 -mtime +7 -exec rm -rf {} \; 2>/dev/null || true"
- name: Clean up
if: always()
run: rm -f private_key
- name: Notify deployment status
if: always()
run: |
if [ "${{ job.status }}" == "success" ]; then
COLOR="#36a64f"
EMOJI="✅"
STATUS="성공"
else
COLOR="#ff0000"
EMOJI="❌"
STATUS="실패"
fi
COMMIT_MESSAGE=$(git log -1 --pretty=%B | tr '\n' ' ' | sed 's/"/\\"/g')
COMMIT_AUTHOR=$(git log -1 --pretty=%an)
COMMIT_SHA_SHORT=$(git rev-parse --short HEAD)
DEPLOY_TIME=$(TZ=Asia/Seoul date '+%Y-%m-%d %H:%M:%S %Z')
if [ ! -z "${{ secrets.SLACK_WEBHOOK_URL }}" ]; then
PAYLOAD='{
"attachments": [
{
"color": "'"${COLOR}"'",
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "'"${EMOJI}"' PlayUs 배포 '"${STATUS}"'",
"emoji": true
}
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*버전:*\n'"${COMMIT_SHA_SHORT}"'"
},
{
"type": "mrkdwn",
"text": "*배포 시간:*\n'"${DEPLOY_TIME}"'"
}
]
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*커밋 메시지:*\n'"${COMMIT_MESSAGE}"'"
},
{
"type": "mrkdwn",
"text": "*작성자:*\n'"${COMMIT_AUTHOR}"'"
}
]
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*배포 환경:*\n'"${NODE_ENV}"'"
}
},
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": "<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|배포 상세 정보 보기>"
}
]
}
]
}
]
}'
curl -X POST -H 'Content-type: application/json' --data "${PAYLOAD}" ${{ secrets.SLACK_WEBHOOK_URL }}
else
echo "Slack webhook URL not configured"
fi