본문으로 건너뛰기
wooncloud

Mac mini로 Next.js 앱 셀프호스팅하기: 전체 구성

Mac mini와 같은 소형 서버를 활용해 클라우드 비용 없이 Next.js 앱을 운영하는 방법을 소개합니다. Cloudflare Tunnel로 인바운드 포트 없이 보안을 유지하고, Docker Compose와 GitHub Actions로 배포를 자동화하며, 오프사이트 백업으로 안정성을 확보하는 전체 아키텍처와 운영 노하우를 상세히 다룹니다

·19 min read· views·

Mac mini로 Next.js 앱 셀프호스팅하기: 전체 구성

클라우드 청구서를 한 번이라도 들여다본 적이 있다면, 개인 프로젝트 하나 띄워두는 데 매달 나가는 비용이 은근히 거슬린다. 작은 VPS 한 대, 매니지드 DB 하나, 객체 스토리지 조금. 따로 보면 푼돈이지만 합치면 커피값 이상이 매달 빠져나간다. 트래픽이 거의 없는 토이 프로젝트라면 더 아깝다.

여기서 다루는 건 그 반대편 선택지다. 책상 위에 굴러다니는 Mac mini, 혹은 중고로 산 미니 PC 한 대로 Next.js 앱을 인터넷에 공개하고, 실제로 안정적으로 운영하는 전체 구성. 클라우드 한 줄 없이도 HTTPS·CDN·WAF가 다 붙는다.

왜 셀프호스팅인가

선택의 이유는 대체로 세 가지로 모인다.

  • 비용이 거의 0에 수렴한다. 머신은 이미 있고(혹은 한 번 사두면 끝), 전기료는 미니 PC 기준 월 1천~2천 원 수준이다. 뒤에서 쓸 Cloudflare Tunnel과 CDN/WAF는 무료 플랜으로 충분하다. 도메인 값만 1년에 한 번 나간다.
  • 배우는 게 많다. 리버스 프록시, TLS, 컨테이너 오케스트레이션, DB 운영, 백업 전략을 직접 손으로 엮어보게 된다. 매니지드 서비스가 가려주던 레이어를 한 번씩 다 만져보는 경험은 그대로 실무 감각이 된다.
  • 데이터와 환경을 완전히 통제한다. DB가 내 디스크에 있고, 어떤 버전의 런타임을 쓸지, 어떤 cron을 돌릴지 전부 내 결정이다. 벤더 종속도, 갑작스러운 가격 정책 변경도 없다.

물론 공짜 점심은 아니다. 솔직하게 단점부터 짚고 가자.

  • 가용성은 온전히 내 몫이다. 집 인터넷이 끊기거나 정전이 나면 사이트도 같이 죽는다. 99.9% SLA 같은 건 없다. 머신이 한 대뿐이면 그게 곧 단일 장애점이다.
  • 관리 부담을 떠안는다. OS 업데이트, 보안 패치, 디스크 가득 참, 컨테이너가 새벽에 죽는 일까지 전부 내가 본다. 매니지드 서비스가 대신 자던 새벽을 이제 내가 깬다.
  • 이게 회사 서비스가 아니어야 한다. 개인 블로그, 포트폴리오, 토이 프로젝트, 사이드 프로젝트 정도가 적정선이다. 매출이 걸린 서비스를 집 서버에 올리는 건 권하지 않는다.

요약하면, 다운타임을 웃어넘길 수 있는 규모의 프로젝트라면 셀프호스팅의 득이 실보다 크다. 그 전제 위에서 구성을 본다.

전체 아키텍처

핵심 아이디어는 하나다. 집 서버는 인바운드 포트를 단 하나도 열지 않는다. 대신 서버 안에서 Cloudflare로 바깥을 향해 연결을 걸고(아웃바운드), 그 터널을 통해 트래픽을 받는다. 공인 IP도, 포트포워딩도, 공유기 설정도 필요 없다.

Mac mini 셀프호스팅 아키텍처

흐름을 따라가 보자.

  1. 방문자가 https://example.com에 접속하면 DNS가 Cloudflare 엣지로 보낸다.
  2. 엣지에서 TLS가 종료된다. 인증서 발급·갱신을 Cloudflare가 알아서 한다. 내 서버는 인증서를 만질 일이 없다.
  3. 엣지는 정적 자산은 CDN 캐시로 바로 응답하고, WAF가 악성 요청을 걸러낸다.
  4. 캐시에 없는 요청만 터널을 통해 집 서버의 cloudflared로 들어온다. 이 연결은 서버가 부팅 시 바깥으로 먼저 맺어둔 것이다.
  5. cloudflared가 Docker 내부 네트워크로 요청을 넘기면 web 컨테이너(Next.js)가 응답한다. 필요하면 db 컨테이너의 PostgreSQL을 조회한다.

이 구조의 핵심은 4번이다. 방화벽에 인바운드 규칙이 0개다. 외부에서 내 서버로 직접 들어올 경로 자체가 없으니, 흔한 포트 스캔·무차별 대입 공격의 표적이 되지 않는다. 공인 IP도 노출되지 않는다.

구성 요소별 선택 이유

Next.js output: "standalone" — 슬림한 이미지

작은 머신에서는 디스크와 빌드 시간이 곧 비용이다. Next.js의 standalone 출력은 여기서 결정적이다.

next.config에 한 줄을 넣으면,

// next.config.ts
const nextConfig = {
  output: "standalone",
};
 
export default nextConfig;

빌드 시 .next/standalone 디렉터리에 실행에 필요한 파일만 추려서 담아준다. 의존성 트리를 추적해 실제로 쓰는 패키지만 골라내므로, 수백 MB짜리 node_modules 전체를 이미지에 넣을 필요가 없다. Dockerfile은 멀티스테이지로 짜서 빌드 산출물만 최종 이미지로 복사한다.

# Dockerfile (개략)
FROM node:22-slim AS base
 
FROM base AS builder
WORKDIR /app
COPY . .
RUN corepack enable && pnpm install --frozen-lockfile && pnpm build
 
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# standalone 출력만 복사 — node_modules 전체 불필요
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

최종 이미지에는 node_modules 전체 대신 추려진 의존성과 server.js 진입점만 들어간다. 이미지가 가벼우면 빌드도 빠르고, 머신 디스크도 덜 먹고, 배포 시 레이어 전송량도 준다. 작은 서버에서 이 차이는 체감된다.

한 가지 주의점. standalone은 빌드 시점에 추적 가능한 의존성만 담는다. 런타임에 동적으로 읽는 파일(예: 일부 정적 자산, 직접 fs로 접근하는 리소스)은 빠질 수 있으니, 그런 경로는 Dockerfile에서 명시적으로 복사하거나 outputFileTracingIncludes 설정으로 챙겨야 한다.

Docker Compose — 한 묶음으로 관리

웹·DB·터널을 따로따로 띄우고 관리하면 금방 피곤해진다. Compose로 세 서비스를 한 파일에 묶으면, up 한 번으로 전부 기동되고 down 한 번으로 전부 정리된다. 서비스 간 통신은 Compose가 만들어주는 내부 네트워크에서 서비스 이름으로 해결된다. webdb:5432로 DB에 붙고, cloudflaredweb:3000으로 트래픽을 넘긴다. 호스트 포트를 열 필요가 없다.

DB 데이터는 named volume에 둬서 컨테이너를 지웠다 다시 만들어도 데이터가 살아남게 한다. 이게 셀프호스팅에서 제일 자주 실수하는 지점이다. 볼륨 없이 띄우면 컨테이너 재생성 한 번에 DB가 통째로 날아간다.

Cloudflare Tunnel — 포트를 열지 않는다

전통적인 방식은 공유기에서 80/443 포트를 집 서버로 포워딩하고, 공인 IP를 도메인에 연결하고, Let's Encrypt로 인증서를 직접 갱신하는 것이다. 동작은 하지만 문제가 많다. 공인 IP가 그대로 노출되고, 가정용 회선은 대개 유동 IP라 바뀔 때마다 손이 가고, 열어둔 포트는 그날부터 전 세계 스캐너의 표적이 된다.

Cloudflare Tunnel은 이걸 뒤집는다. 서버 안의 cloudflared 데몬이 Cloudflare 엣지로 아웃바운드 연결을 맺고, 모든 트래픽이 그 연결을 타고 들어온다. 그래서,

  • 인바운드 포트가 0개다. 공유기 포트포워딩도, 방화벽 인바운드 규칙도 필요 없다.
  • 공인 IP가 노출되지 않는다. 공격 표면 자체가 사라진다.
  • 유동 IP여도 무관하다. 연결을 서버가 거니까 IP가 바뀌어도 알아서 다시 붙는다.
  • TLS를 Cloudflare가 종료한다. 인증서 발급·갱신을 신경 쓸 일이 없다.

대가는 트래픽이 Cloudflare를 경유한다는 점이다. 즉 그들을 신뢰 경계 안에 들이는 셈이다. 개인 프로젝트 수준에서는 받아들일 만한 트레이드오프지만, 의식하고 있어야 한다.

배포 자동화 — push 한 번으로

수동 배포는 처음 몇 번은 괜찮지만 곧 귀찮아진다. git push만 하면 서버가 알아서 새 이미지를 빌드하고 컨테이너를 갈아끼우게 만드는 게 목표다.

여기에 GitHub Actions self-hosted runner가 잘 맞는다. 클라우드 러너 대신, 집 서버에 러너 에이전트를 하나 띄워둔다. main에 push가 들어오면 GitHub가 그 러너에 작업을 보내고, 러너는 바로 그 서버에서 체크아웃·빌드·docker compose up -d를 수행한다. 이미지를 외부 레지스트리에 올렸다 내릴 필요 없이 로컬에서 빌드해 곧장 교체하니 빠르고 단순하다.

흐름만 요약하면 이렇다.

git push (main)
   └─▶ GitHub Actions 트리거
         └─▶ self-hosted runner (집 서버에서 실행)
               ├─ git checkout
               ├─ docker compose build web
               └─ docker compose up -d   # 새 컨테이너로 교체

DB 스키마 마이그레이션을 언제 돌릴지, 무중단 교체를 어떻게 보장할지 같은 디테일이 있는데, 파이프라인 자체는 그 자체로 글 한 편 분량이라 여기서는 개요만 짚는다. 핵심은 "push가 곧 배포"라는 루프를 self-hosted runner로 닫을 수 있다는 것이다.

운영에서 챙길 것들

띄우는 것보다 굴리는 게 어렵다. 작은 서버를 오래 안정적으로 돌리려면 다음을 미리 정해둬야 한다.

DB 백업은 오프사이트로. 이게 제일 중요하다. 집 서버 디스크 하나에 모든 게 들어있다는 건, 그 디스크가 죽으면 전부 사라진다는 뜻이다. 정기적으로 pg_dump를 떠서 다른 물리적 장소로 보내라. 객체 스토리지든, 다른 클라우드든, 별도 GitHub 비공개 repo든 좋다. 핵심은 "서버와 함께 죽지 않는 곳"이어야 한다는 것. cron으로 매일 자동화하고, 가끔은 실제로 복원이 되는지 한 번씩 검증하라. 복원해본 적 없는 백업은 백업이 아니다.

재시작과 무중단. 컨테이너에 restart: unless-stopped를 걸어두면 정전 복구나 데몬 재시작 후 자동으로 다시 뜬다. Mac이라면 머신 자체의 자동 로그인·전원 복구 후 부팅 설정도 같이 켜둬야 진짜 무인 복구가 된다. 배포 시 무중단까지 원하면 컨테이너를 두 벌 굴리며 교체하는 식의 구성이 필요한데, 트래픽이 적은 개인 프로젝트라면 몇 초의 재시작 다운타임은 대개 그냥 감수하는 편이 단순하다.

도메인과 DNS. 도메인을 Cloudflare에 등록(또는 네임서버 위임)해 두면 터널 연결과 DNS 레코드 관리가 한곳에서 끝난다. 터널을 만들면 example.com을 그 터널로 가리키는 CNAME이 자동으로 잡힌다. 서브도메인으로 여러 서비스(api., admin. 등)를 한 터널에 태우는 것도 가능하다.

리소스 관리. 작은 머신은 메모리·디스크가 빠듯하다. 앞서 본 이미지 슬림화가 첫 단추고, 그다음은 정리다. 안 쓰는 이미지·빌드 캐시가 디스크를 갉아먹으니 docker image prune을 주기적으로(혹은 cron으로) 돌려라. PostgreSQL 메모리 설정도 머신 사양에 맞게 낮춰두는 게 좋다. 기본값은 종종 작은 머신엔 과하다.

예시 docker-compose 골격

세 서비스를 한 파일에 묶은 최소 골격이다. 실제 값(비밀번호, 터널 토큰, 도메인)은 환경변수로 빼고, 절대 파일에 직접 적지 않는다.

# docker-compose.yml (개략 — 일반 예시)
services:
  web:
    build: .                      # 위 Dockerfile 로 빌드
    environment:
      DATABASE_URL: postgresql://app:${DB_PASSWORD}@db:5432/appdb
      NODE_ENV: production
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped
    # 호스트 포트 노출 없음 — cloudflared 가 내부에서 web:3000 으로 접근
 
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: appdb
    volumes:
      - db-data:/var/lib/postgresql/data   # named volume — 데이터 영속
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped
    # 5432 도 외부로 열지 않음 — 내부 네트워크에서만 web 이 접근
 
  cloudflared:
    image: cloudflare/cloudflared:latest
    command: tunnel --no-autoupdate run
    environment:
      TUNNEL_TOKEN: ${TUNNEL_TOKEN}     # Cloudflare 대시보드에서 발급
    depends_on:
      - web
    restart: unless-stopped
    # 인바운드 포트 없음 — 엣지로 아웃바운드 연결만 맺는다
 
volumes:
  db-data:

몇 가지 짚어둘 점.

  • 어떤 서비스도 ports:로 호스트 포트를 열지 않는다. 외부 접근은 전적으로 cloudflared 터널을 통해서만 이뤄진다. 디버깅 중 잠깐 web을 직접 보고 싶을 때만 ports: ["127.0.0.1:3000:3000"]처럼 로컬호스트에 한정해 여는 정도로 충분하다.
  • 비밀값은 전부 ${...}로 환경변수에서 주입한다. .env 파일에 두되 그 파일은 git에 올리지 않는다(.gitignore).
  • depends_oncondition: service_healthy로 DB가 준비된 뒤 web이 뜨게 해 부팅 순서 경합을 막는다.
  • cloudflared의 라우팅(어느 도메인을 web:3000으로 보낼지)은 Cloudflare 대시보드에서 터널에 설정한다. 토큰 방식이면 라우팅 설정이 토큰에 묶여 내려오므로 compose에는 토큰만 있으면 된다.

정리

비싼 클라우드 없이도 개인 프로젝트 하나 인터넷에 안정적으로 띄우는 데 필요한 건 의외로 단출하다.

  • 작은 머신 — Mac mini든 중고 미니 PC든. 한 번 사두면 전기료만 든다.
  • 컨테이너 — Docker Compose로 web·db·터널을 한 묶음으로. output: "standalone"으로 이미지를 슬림하게.
  • 터널 — Cloudflare Tunnel로 인바운드 포트 0개로 공개. TLS·CDN·WAF는 엣지가 공짜로 얹어준다.

여기에 self-hosted runner로 배포를 자동화하고, 오프사이트 DB 백업만 빠뜨리지 않으면, 트래픽이 폭증하지 않는 한 개인 프로젝트는 이 조합으로 충분히 오래 잘 굴러간다. 매니지드 서비스가 가려주던 레이어를 직접 엮어보는 경험은 덤이다.

가용성이 전적으로 내 손에 달려 있다는 점만 늘 의식하면 된다. 그 한 가지를 받아들일 수 있는 규모의 프로젝트라면, 책상 위 작은 서버 한 대가 생각보다 멀리 데려다준다.