미니PC로 홈서버 구축하기2 - Proxmox + Docker + GitHub Actions CI/CD 가이드
v2 블로그 온프레미스 전환기를 다룹니다. (프론트서버 + 백엔드서버)
전체 아키텍처
[개발자 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.ts에 output: '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.yml의 env_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.tsxapp/(public)/blog/[id]/page.tsxapp/(public)/news/[id]/page.tsxapp/(public)/profile/page.tsx
배운 점
NEXT_PUBLIC_변수는 빌드 시 번들링 → 런타임 변경 불가, 재빌드 필수- 도커 네트워크는 컨테이너명을 DNS처럼 사용 → 같은 네트워크에 있어야 통신 가능
- Next.js rewrites를 활용하면 백엔드를 외부에 노출하지 않고 내부 IP로 프록시 가능
- SSG/ISR은 빌드 환경에 백엔드가 필요 → CI 환경에서는
force-dynamic권장