---
title: "Next.js NEXT_PUBLIC 빌드타임 인라인이 정적 페이지 canonical 을 localhost 로 샌다"
description: "Docker 로 셀프호스팅한 Next.js 정적 페이지의 canonical 과 og:url 이 localhost 로 박혀 색인이 안 됐습니다. NEXT_PUBLIC 은 빌드타임에 인라인되니 build args 로 넘겨야 합니다."
date: 2026-11-03
updated: 2026-11-03T10:15:33.704Z
tags: [nextjs, seo, docker, env]
canonical: https://blog.wooncloud.com/posts/nextjs-public-env-canonical-localhost
---

Next.js(App Router) 블로그를 Docker 로 셀프호스팅하고 있었는데, Google Search Console 에 `/about`, `/contact` 같은 정적 페이지가 "발견됨 - 현재 색인이 생성되지 않음" 으로 잡혔습니다.

실서버 HTML 을 확인해 보니 이랬습니다.

```html
<link rel="canonical" href="http://localhost:3000/about">
```

canonical 과 `og:url` 이 전부 `localhost` 로 박혀 있었습니다. 그런데 DB 에서 동적 렌더되는 글 상세 페이지는 멀쩡했습니다.

![빌드 컨테이너에 환경변수가 없으면 정적 페이지의 canonical이 localhost로 굳는다.](/images/posts/nextjs-public-env-canonical-localhost/78756c84-7e40-40f6-bdc8-aeac0f55a537.webp)

## 같은 코드인데 왜 페이지마다 다를까?

사이트 URL 은 보통 이런 폴백으로 잡습니다.

```ts
process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"
```

원인은 **`NEXT_PUBLIC_*` 환경변수가 런타임이 아니라 빌드타임에 문자열로 인라인된다**는 점입니다. 빌드 컨테이너(`docker build`)에 변수를 넘기지 않으면 빌드 시점에는 값이 없으니, 폴백인 `localhost:3000` 이 코드에 그대로 박힙니다.

그런데 같은 `metadataBase: new URL(SITE.url)` 를 쓰는데도 페이지마다 달랐습니다. 렌더 시점이 다르기 때문입니다.

정적 생성(SSG) 페이지는 빌드 시점에 HTML 이 확정되니, 그때의 `localhost` 가 canonical 에 굳습니다. `export const dynamic = "force-dynamic"` 라우트(sitemap, feed 등)는 요청 시점에 평가됩니다. 런타임 컨테이너에는 env 가 있으니 정상이죠.

canonical 이 도달할 수 없는 호스트를 가리키면 크롤러는 색인을 꺼립니다. 단순한 오타가 아니라 색인을 적극적으로 방해하는 SEO 버그입니다.

![빌드 컨테이너에 NEXT_PUBLIC_SITE_URL 이 없으면 정적 페이지 HTML 에 폴백 localhost 가 굳고, 같은 값이 있는 런타임 컨테이너에서 요청 시점에 평가되는 force-dynamic 라우트만 정상으로 나온다.](/images/posts/nextjs-public-env-canonical-localhost/868beb71-f3d4-4b49-9c28-e9e764a7a9c5.svg)

## 빌드 스테이지에 직접 넘깁니다

`NEXT_PUBLIC_*` 는 클라이언트 번들과 정적 HTML 에 빌드타임으로 인라인되니, 빌드 스테이지에 반드시 주입해야 합니다. 멀티스테이지 Dockerfile 의 builder 단계에서 `ARG` 와 `ENV` 로 받고, compose 에서는 build args 로 전달합니다.

```dockerfile
# builder 에 NEXT_PUBLIC_* 를 전달하지 않으면
# 정적 HTML 에 폴백값(localhost)이 인라인된다.
FROM node:20-alpine AS builder
WORKDIR /app
ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
COPY . .
RUN npm run build
```

```yaml
# docker-compose.yml: 호스트 .env 값을 빌드 args 로 전달
services:
  web:
    build:
      context: .
      args:
        NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-}
    env_file: .env   # 런타임용 (force-dynamic 라우트가 읽는 값)
```

`env_file` 은 런타임용이라, 빌드타임 인라인 변수는 이것만으로 채워지지 않습니다. `build.args` 로 따로 줘야 합니다.

## 확인은 빌드 산출물로 합니다

런타임이 아니라 빌드 산출물을 봐야 합니다. 정적 페이지의 canonical 은 이렇게 확인합니다.

```bash
curl -s https://example.com/about | grep canonical
```

`/robots.txt` 의 `Sitemap:`, `Host:` 줄도 같이 봅니다. 동적 라우트만 보면 멀쩡해 보여서 놓치기 쉽습니다.

정적 페이지는 빌드에서, 동적 라우트는 런타임에서 값이 정해진다는 걸 놓친 게 원인이었습니다. 환경변수가 빌드에 필요한지 런타임에 필요한지는 따로 구분해 둬야겠습니다.

## 참고

- https://nextjs.org/docs/app/building-your-application/configuring/environment-variables
- https://nextjs.org/docs/app/api-reference/functions/generate-metadata#metadatabase
- https://docs.docker.com/build/building/variables/#arg-usage-example
