인프라CI / CD
5

미니PC로 홈서버 구축하기2 - Proxmox + Docker + GitHub Actions CI/CD 가이드

v2 블로그 온프레미스 전환기를 다룹니다. (프론트서버 + 백엔드서버)

미니PC로 홈서버 구축하기2 - Proxmox + Docker + GitHub Actions CI/CD 가이드

전체 아키텍처

[개발자 PC]
    │ git push
    ▼
[GitHub Actions]
    ├─ Next.js 빌드 → GHCR(ghcr.io)에 이미지 Push
    └─ Spring Boot 빌드 → GHCR에 이미지 Push
          │
          │ SSH 접속 (포트 2222)
          ▼
[미니PC - 192.168.55.83]
    ├─ blog-v2-frontend  (Next.js  :3000)
    ├─ blog-v2-backend   (Spring Boot :8888)
    └─ blog-v1-mysql     (MySQL :3306)
          │
          │ 공유기 포트포워딩
          ▼
[외부 사용자]
    └─ blog.minseok.life → Next.js :3000

핵심 흐름: git push 한 번으로 빌드부터 배포까지 자동화. 빌드는 GitHub 서버에서 하므로 미니PC 리소스 소모 없음.


Dockerfile 설정

Next.js (프론트엔드)

FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

CMD ["node", "server.js"]

next.config.tsoutput: 'standalone' 추가 필수. 이미지 크기 1GB+ → 100~200MB로 감소.

Spring Boot (백엔드)

FROM gradle:8-jdk21-alpine AS builder
WORKDIR /app
COPY . .
RUN gradle build -x test --no-daemon

FROM eclipse-temurin:21-jre-alpine AS runner
WORKDIR /app

COPY --from=builder /app/build/libs/*.jar app.jar

EXPOSE 8888

ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=prod", "app.jar"]

GitHub Actions CI/CD

deploy.yml (프론트엔드 / 백엔드 동일한 구조)

name: Deploy Blog V2_front

on:
  push:
    branches: [master]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: 체크아웃
        uses: actions/checkout@v3

      - name: GHCR 로그인
        uses: docker/login-action@v2
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: 이미지 빌드 & Push
        uses: docker/build-push-action@v4
        with:
          context: .
          push: true
          tags: ghcr.io/misnawk/blog-v2_front:latest

      - name: SSH 배포
        uses: appleboy/ssh-action@v0.1.10
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_KEY }}
          port: ${{ secrets.SSH_PORT }}
          script: |
            echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ secrets.GHCR_USERNAME }} --password-stdin
            cd /opt/blog-v2
            docker compose pull
            docker compose up -d

GitHub Secrets 설정 목록

SSH_HOST      = 미니PC 공인IP
SSH_USER      = root
SSH_KEY       = SSH 개인키 내용
SSH_PORT      = 2222
GHCR_TOKEN    = GitHub Personal Access Token
GHCR_USERNAME = misnawk

왜 GHCR_TOKEN이 필요한가?
GitHub Actions는 빌드/push 권한이 있지만, 미니PC는 별개의 머신이라 GHCR 이미지를 pull할 때 별도 인증이 필요하다.
GHCR 패키지를 Public으로 설정하면 이 두 Secret을 생략할 수 있다.


Docker Compose 설정

/opt/blog-v2/docker-compose.yml

networks:
  blog-shared-network:
    external: true

services:
  backend:
    image: ghcr.io/misnawk/blog-v2_back:latest
    container_name: blog-v2-backend
    restart: unless-stopped
    ports:
      - "8888:8888"
    env_file:
      - .env.backend
    networks:
      - blog-shared-network

  frontend:
    image: ghcr.io/misnawk/blog-v2_front:latest
    container_name: blog-v2-frontend
    restart: unless-stopped
    ports:
      - "3000:3000"
    networks:
      - blog-shared-network

/opt/blog-v2/.env.backend

SPRING_PROFILES_ACTIVE=prod
PORT=8888
SPRING_DATASOURCE_URL=jdbc:mysql://blog-v1-mysql:3306/blog_v2?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul
SPRING_DATASOURCE_USERNAME=root
SPRING_DATASOURCE_PASSWORD=****
R2_ACCOUNT_ID=****
R2_ACCESS_KEY=****
R2_SECRET_KEY=****
R2_BUCKET=blog
R2_PUBLIC_URL=https://****.r2.dev
JWT_SECRET=****

.env.backend는 반드시 .gitignore에 추가할 것.


Docker 네트워크 설계

MySQL을 v1과 v2가 공유하는 구조라 네트워크 설계가 중요했다.

문제 상황

v1 docker-compose가 만든 네트워크(blog-v1_blog-v1-network)에 v2가 external로 합류하면, v1이 꺼지면 네트워크도 사라져 v2도 DB 연결이 끊기는 문제가 발생한다.

해결: 공용 네트워크 분리

# 한 번만 실행
docker network create blog-shared-network
blog-shared-network
├── blog-v1-mysql  (v1 compose에서 연결)
├── blog-v2-backend
└── blog-v2-frontend

v1, v2 모두 blog-shared-network에 합류하고 MySQL도 이 네트워크에 연결함으로써 서로 독립적으로 실행/종료 가능해졌다.


Next.js 프록시 설정 (핵심 트러블슈팅)

문제

use client 컴포넌트에서 백엔드를 직접 호출할 때 내부 IP(192.168.55.83:8888)를 사용하면 외부 사용자 브라우저에서 접근 불가.

외부 사용자 브라우저 → 192.168.55.83:8888 호출 → ❌ 내부 IP 접근 불가

해결: Next.js rewrites 프록시

next.config.ts:

const nextConfig: NextConfig = {
  output: 'standalone',
  async rewrites() {
    return [
      {
        source: '/api-proxy/:path*',
        destination: 'http://192.168.55.83:8888/:path*',
      },
    ]
  },
}

.env.production:

NEXT_PUBLIC_API_URL=/api-proxy
외부 브라우저 → /api-proxy/public/blog (같은 도메인, 가능)
    └─ Next.js rewrites
    └─ http://192.168.55.83:8888/public/blog ✅

백엔드가 외부에 직접 노출되지 않아 보안상 유리하고 포트포워딩도 불필요하다.


트러블슈팅

1. GHCR 권한 오류

installation not allowed to Create organization package

원인: GitHub Actions의 쓰기 권한 미설정
해결: 레포 Settings → Actions → General → Workflow permissions → Read and write permissions 선택


2. SSH 연결 타임아웃

원인: SSH 포트 불일치 (실제 서버는 22, Secrets에는 2222로 설정)
해결: /etc/ssh/sshd_config에서 Port 2222로 변경 + 공유기 포트포워딩 설정


3. Spring Boot 시작 실패 - Dialect 오류

Unable to determine Dialect without JDBC metadata

원인: SPRING_DATASOURCE_URL 환경변수가 컨테이너에 전달되지 않음
해결: docker-compose.ymlenv_file 경로 확인 및 .env.backend 작성


4. NEXT_PUBLIC 환경변수가 Railway 주소 그대로

원인: NEXT_PUBLIC_ 변수는 빌드 시점에 번들링되므로 런타임 환경변수 변경으로는 적용 안 됨
해결: .env.production 수정 후 git push → 재빌드 필수


5. 빌드 시 ECONNREFUSED

TypeError: Failed to parse URL from /api-proxy/public/blog
AggregateError: ECONNREFUSED

원인: Next.js SSG/ISR 방식은 빌드 시점에 API를 호출하는데, GitHub Actions 빌드 환경에는 백엔드가 없음
해결: 서버 컴포넌트에 force-dynamic 추가 → 빌드 시 API 호출 생략, 런타임에만 호출

// app/(public)/page.tsx 등 fetch를 직접 쓰는 모든 서버 컴포넌트
export const dynamic = 'force-dynamic'

대상 파일:

  • app/(public)/page.tsx
  • app/(public)/blog/[id]/page.tsx
  • app/(public)/news/[id]/page.tsx
  • app/(public)/profile/page.tsx

배운 점

  • NEXT_PUBLIC_ 변수는 빌드 시 번들링 → 런타임 변경 불가, 재빌드 필수
  • 도커 네트워크는 컨테이너명을 DNS처럼 사용 → 같은 네트워크에 있어야 통신 가능
  • Next.js rewrites를 활용하면 백엔드를 외부에 노출하지 않고 내부 IP로 프록시 가능
  • SSG/ISR은 빌드 환경에 백엔드가 필요 → CI 환경에서는 force-dynamic 권장
#온프레미스#도커#깃허브액션#proxmox#CI/CD

댓글

(0)
미니PC로 홈서버 구축하기2 - Proxmox + Docker + GitHub Actions CI/CD 가이드 | 강민석의 개발블로그