flowoodz.
← blog
10분 읽기글 flowoodz

Next.js를 Coolify로 셀프호스팅한 기록

Vercel 대신 Coolify + Docker standalone 빌드로 직접 배포하며 헬스체크 프로브 실패를 잡은 과정.

목차
  1. 이미 있던 서버를 쓰기로 했습니다
  2. standalone 빌드로 이미지 줄이기
  3. 헬스체크 프로브가 계속 실패했습니다
  4. Cloudflare 쪽에서 걸린 것
  5. Uptime Kuma까지 물리면 끝
  6. 프로브 실패가 남긴 운영 기준

flowoodz.com 브랜드 허브를 Next.js로 만들고, 배포는 Vercel이 아니라 집에 있는 서버의 Coolify로 했습니다. plan, yt, capybara 같은 서비스들과 Uptime Kuma, n8n이 이미 같은 서버의 Coolify 위에서 돌고 있어서, 허브만 매니지드 플랫폼에 따로 두는 게 오히려 어색한 상황이었습니다.

결과부터 말하면 배포 자체는 어렵지 않았고, 시간을 잡아먹은 건 Coolify 헬스체크 프로브가 계속 실패하던 문제 하나였습니다. 그 과정을 남깁니다.


이미 있던 서버를 쓰기로 했습니다#

  • 서버가 이미 있고, Coolify가 이미 다른 앱들을 돌리고 있었습니다. 앱 하나 추가하는 비용이 사실상 0원입니다.
  • 도메인·모니터링(Uptime Kuma)·자동화(n8n)가 전부 한 서버에 모여 있어서, 배포 구성을 한 군데서 보고 싶었습니다.
  • Coolify는 GitHub repo와 Dockerfile만 있으면 push → webhook → 빌드 → 배포가 자동으로 돌아갑니다. Vercel에서 익숙하던 흐름과 크게 다르지 않았습니다.

대신 매니지드가 해 주던 이미지 최적화된 런타임, 헬스체크, HTTPS를 직접 챙겨야 합니다. 여기서 삽질이 시작됐습니다.


standalone 빌드로 이미지 줄이기#

Next.js standalone 빌드의 Docker 멀티스테이지 구조. deps, builder, runner 3단계로 나뉘고 public과 .next/static은 standalone에 포함되지 않아 직접 복사해야 합니다

Next.js를 Docker로 배포할 때 기본 빌드를 그대로 쓰면 node_modules 전체가 이미지에 들어갑니다. output: "standalone"을 켜면 서버 구동에 필요한 파일만 .next/standalone으로 추려 줍니다.

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

여기서 주의할 점이 두 가지 있었습니다.

  • public/과 .next/static은 standalone에 포함되지 않습니다. Dockerfile에서 직접 복사해 줘야 합니다. 빼먹으면 서버는 뜨는데 정적 자산만 404가 납니다.
  • NEXT_PUBLIC_* 환경변수는 빌드 시점에 확정됩니다. 다만 애드센스 클라이언트 ID는 이미 공개되는 값이라 지금은 환경변수 없이 코드에 직접 명시합니다. 이 값을 Coolify 빌드에 전달하지 않아 광고 코드가 빠졌던 문제는 애드센스 반려 기록에 덧붙였습니다.

최종 Dockerfile은 deps → builder → runner 3단 멀티스테이지입니다.

FROM node:22-alpine AS deps
WORKDIR /app
RUN apk add --no-cache libc6-compat
COPY package.json package-lock.json ./
RUN npm ci
 
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
 
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
 
RUN apk add --no-cache wget \
  && 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
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=5 \
  CMD wget -qO- http://127.0.0.1:3000/health >/dev/null || exit 1
CMD ["node", "server.js"]

apk add wget 한 줄이 왜 들어갔는지가 이 글의 핵심입니다.


헬스체크 프로브가 계속 실패했습니다#

첫 배포를 올렸더니 컨테이너는 분명히 떠 있는데 Coolify가 앱을 unhealthy로 판정했습니다. 로컬에서 docker run 하면 멀쩡히 응답하는 앱이었습니다.

원인은 두 가지였습니다.

  1. 헬스체크가 칠 엔드포인트가 없었습니다. 처음 이미지에는 /health 같은 경로가 없어서, 프로브가 무엇을 확인해야 할지부터 정의가 안 된 상태였습니다.
  2. 프로브가 쓰는 wget이 이미지에 없었습니다. 헬스체크 커맨드가 컨테이너 안에서 실행되는데, 최소한으로 깎아 둔 런타임 이미지에는 그 명령이 없었습니다. 앱이 정상이어도 프로브 커맨드 자체가 실패하니 결과는 무조건 unhealthy입니다.

"앱이 죽었다"와 "앱이 살았는지 확인하는 도구가 죽었다"는 겉보기 증상이 같습니다. 이걸 구분 못 하면 애먼 앱 코드만 계속 들여다보게 됩니다.

해결은 커밋 두 개로 끝났습니다.

먼저 /health 라우트를 추가했습니다. App Router의 Route Handler로 평문 ok만 돌려줍니다.

// src/app/health/route.ts
import { NextResponse } from "next/server";
 
/** Coolify / Uptime Kuma health probe */
export function GET() {
  return new NextResponse("ok", {
    status: 200,
    headers: {
      "content-type": "text/plain; charset=utf-8",
      "cache-control": "no-store",
    },
  });
}

cache-control: no-store를 박은 이유는 뒤에 Cloudflare 프록시가 있기 때문입니다. 헬스체크 응답이 캐시에 물리면 죽은 앱도 살아 있는 것처럼 보일 수 있습니다.

다음으로 runner 스테이지에 apk add --no-cache wget을 넣고, HEALTHCHECK를 Dockerfile에 명시했습니다. --start-period=20s는 Next.js 서버가 뜨는 시간을 벌어 주는 값입니다. 이게 없으면 부팅 중에 프로브가 먼저 돌아서 초기 실패가 쌓입니다.

이 두 커밋 이후 프로브가 통과했고, Coolify 대시보드의 상태가 healthy로 바뀌었습니다.


Cloudflare 쪽에서 걸린 것#

DNS는 Cloudflare에 뒀습니다. apex A 레코드와 www CNAME 모두 proxied로 켜고, SSL/TLS 모드는 Full, Always Use HTTPS를 켰습니다.

여기서 하나 걸렸던 게 전역 Redirect Rule입니다. 원래 apex 도메인을 서브도메인 서비스로 넘기는 리다이렉트가 있었는데, 이걸 남겨 두면 /뿐 아니라 /ads.txt까지 전부 리다이렉트돼 버립니다. 애드센스는 ads.txt를 apex에서 직접 읽어야 하므로 이 룰은 반드시 제거해야 했습니다. 배포 문서에도 "apex 전역 Redirect Rule 없음"을 체크 항목으로 박아 뒀습니다.


Uptime Kuma까지 물리면 끝#

Coolify의 헬스체크는 컨테이너 관점이고, 밖에서 실제 도메인이 살아 있는지는 별개 문제입니다. 같은 서버에서 돌던 Uptime Kuma에 https://flowoodz.com/health 모니터를 하나 추가했습니다. /health 엔드포인트 하나가 Docker HEALTHCHECK와 외부 모니터링 두 곳에서 재사용되는 구조입니다.

재배포는 git push origin main 후 Coolify webhook이 받아서 처리하고, 급하면 Coolify API를 curl로 직접 쳐서 강제 배포합니다(webhook과 겹친 경우는 이 글에 적었습니다). 배포가 끝나면 이 네 줄로 확인합니다.

curl -sSI https://flowoodz.com/ | head -15
curl -sS https://flowoodz.com/health
curl -sS https://flowoodz.com/ads.txt
curl -sSI https://www.flowoodz.com/ | head -10

프로브 실패가 남긴 운영 기준#

  1. standalone 빌드는 필요한 파일을 전부 묶어 주지 않습니다. public/과 .next/static은 Dockerfile에서 직접 복사해야 합니다.
  2. 헬스체크는 "앱 + 프로브 커맨드 + 엔드포인트" 세 개가 전부 살아야 통과합니다. 최소화한 alpine 이미지에서는 프로브가 쓸 바이너리(wget)부터 있는지 확인할 것.
  3. /health 하나 잘 만들어 두면 여러 군데서 씁니다. Docker HEALTHCHECK, Coolify 상태 판정, Uptime Kuma 모니터가 전부 같은 엔드포인트를 봅니다.
  4. 프록시 뒤의 헬스체크 응답에는 no-store. 캐시된 "ok"는 모니터링을 무력화합니다.

Vercel이었으면 이 글에 적은 문제의 절반은 겪지 않았을 겁니다. 대신 서버 한 대에 허브·서비스·모니터링·자동화가 모두 모였고, 앱을 하나 더 올리는 비용이 커밋 몇 개 수준으로 내려왔습니다. 이 정도 트레이드오프면 셀프호스팅 쪽이 남는 장사였습니다.