Mac mini로 Next.js 앱 셀프호스팅하기: 전체 구성
Mac mini 로 Next.js 를 셀프호스팅하면 Cloudflare Tunnel 이 인바운드 포트 0개로 공인 IP 노출 없이 서비스하고, standalone 빌드·self-hosted runner 배포·오프사이트 백업으로 운영합니다.
클라우드 청구서를 한 번이라도 들여다본 적이 있다면, 개인 프로젝트 하나 띄워두는 데 매달 나가는 비용이 은근히 거슬립니다. 작은 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도, 포트포워딩도, 공유기 설정도 필요 없습니다.
흐름을 따라가 봅시다.
- 방문자가
https://example.com에 접속하면 DNS가 Cloudflare 엣지로 보냅니다. - 엣지에서 TLS가 종료됩니다. 인증서 발급·갱신을 Cloudflare가 알아서 합니다. 제 서버는 인증서를 만질 일이 없습니다.
- 엣지는 정적 자산은 CDN 캐시로 바로 응답하고, WAF가 악성 요청을 걸러냅니다.
- 캐시에 없는 요청만 터널을 통해 집 서버의
cloudflared로 들어옵니다. 이 연결은 서버가 부팅 시 바깥으로 먼저 맺어둔 것입니다. 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가 만들어주는 내부 네트워크에서 서비스 이름으로 해결됩니다. web은 db:5432로 DB에 붙고, cloudflared는 web: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_on의condition: 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 백업만 빠뜨리지 않으면, 트래픽이 폭증하지 않는 한 개인 프로젝트는 이 조합으로 충분히 오래 잘 굴러갑니다. 매니지드 서비스가 가려주던 레이어를 직접 엮어보는 경험은 덤입니다.
가용성이 전적으로 제 손에 달려 있다는 점만 늘 의식하면 됩니다. 그 한 가지를 받아들일 수 있는 규모의 프로젝트라면, 책상 위 작은 서버 한 대가 생각보다 멀리 데려다줍니다.