Fresh Notes
Fresh 블로그 배포하기: Docker, Markdown 파일 경로와 production 검증 | Fresh Notes

Fresh

Fresh 블로그 배포하기: Docker, Markdown 파일 경로와 production 검증

Fresh Markdown 블로그를 Docker로 배포하는 구성을 만들고, 콘텐츠 파일 포함 여부, 실행 경로, 공개 URL과 production 응답을 확인하는 방법을 정리합니다.

2026.10.13·Fresh·10 min read

빌드가 성공해도 글 페이지는 실패할 수 있습니다

지금까지 Fresh Notes에 Markdown 콘텐츠 시스템, 서버 검색, 테마 전환을 연결했습니다.

개발 서버에서는 글이 잘 보이고 검색도 동작합니다. 하지만 배포할 때는 개발 환경이 대신 준비해주던 조건을 다시 확인해야 합니다.

특히 이번 블로그에는 일반적인 UI 코드와 다른 의존성이 있습니다.

const source = await Deno.readTextFile(
  "./content/posts/example.md",
);

글 파일을 요청 시점에 읽는다는 점입니다.

빌드가 성공했다고 이 파일의 배치나 실행 중 읽기 권한까지 확인된 것은 아닙니다. 서버가 시작된 뒤 특정 글을 요청할 때 문제가 드러날 수도 있습니다.

이번 글에서는 Docker를 기준으로 배포 구성을 만들고, 서버가 실행된 뒤 확인할 항목을 정리하겠습니다.

4편에서 설명한 Vite와 Deno의 역할은 짧게 연결하고, 여기서는 배포 환경에서 코드와 콘텐츠가 함께 동작하는 조건에 집중합니다.

먼저 production 실행으로 확인합니다

개발 서버 대신 production build를 실행합니다.

deno install --frozen --allow-scripts
deno task check
deno task build
deno task start

--frozen은 의존성 설정과 lockfile이 맞지 않을 때 설치 과정에서 오류를 내도록 합니다. 배포 중에 의존성 구성을 조용히 변경하지 않기 위한 설정입니다.

--allow-scripts는 npm 패키지의 설치 스크립트 실행을 허용합니다. Fresh 공식 배포 안내에서도 의존성 설치 후 빌드하는 흐름을 설명합니다. Deno install, Fresh Deployment

프로젝트의 production 실행 명령은 다음과 같습니다.

deno serve -A _fresh/server.js

여기서 확인할 것은 단순히 서버 시작 메시지가 아닙니다.

서버 시작
    ↓
홈 요청
    ↓
글 상세 요청
    ↓
Markdown 파일 읽기
    ↓
HTML 응답

최소한 글 상세 페이지까지 열어야 콘텐츠를 읽는 경로도 확인할 수 있습니다.

배포 대상에는 Markdown 파일도 포함됩니다

현재 콘텐츠 로더는 런타임에 Markdown 파일을 읽습니다.

HTTP 요청
    ↓
Fresh route
    ↓
Deno.readTextFile()
    ↓
Markdown 렌더링
    ↓
HTML 응답

따라서 배포 환경에도 content/posts가 있어야 합니다. About 같은 고정 페이지를 같은 방식으로 읽는다면 content/pages도 필요합니다.

/app/
├── _fresh/
│   └── server.js
├── content/
│   ├── posts/
│   │   └── ...
│   └── pages/
│       └── ...
├── deno.json
├── deno.lock
└── ...

_fresh/server.js는 서버 코드의 진입점입니다. Deno.readTextFile()에 문자열로 전달한 Markdown 경로까지 자동으로 포함된다고 가정해서는 안 됩니다.

이 관계는 글의 저장 방식을 바꾸기 전까지 유지됩니다.

런타임에 Markdown을 읽는다
→ 배포 파일에도 Markdown이 필요하다

이번 Docker 구성에서는 프로젝트 파일을 복사한 뒤 컨테이너 안에서 빌드합니다. 콘텐츠를 별도로 복사하다가 빠뜨리는 상황을 줄이기 위한 단순한 구성입니다.

상대 경로는 실행 폴더를 기준으로 합니다

5편에서 사용한 경로는 다음과 같습니다.

const POSTS_DIR = "./content/posts";

여기서 ./는 utils/posts.ts 파일이 있는 위치를 뜻하지 않습니다. 프로세스의 현재 작업 디렉터리를 기준으로 해석됩니다.

이 차이를 작은 예제로 확인할 수 있습니다.

project/
├── content/
│   └── posts/
│       └── example.md
└── read.ts

read.ts:

const source = await Deno.readTextFile(
  "./content/posts/example.md",
);

console.log(source);

project 안에서 실행하면 파일을 찾습니다.

deno run --allow-read read.ts

반면 한 단계 위에서 다음처럼 실행하면 스크립트는 찾더라도 콘텐츠 경로는 달라집니다.

deno run --allow-read project/read.ts

실제 재현에서는 다음 오류가 발생했습니다.

NotFound: No such file or directory

스크립트 경로와 콘텐츠의 상대 경로는 별개입니다. Deno의 파일 읽기 API와 현재 작업 디렉터리 기준을 함께 확인해야 합니다. Deno File System API

이번 배포에서는 프로젝트 루트를 /app으로 정하고, 그곳에서 서버를 실행하겠습니다.

WORKDIR /app

현재 로더를 유지한다면 실행 폴더를 고정하는 것도 배포 설정의 일부입니다.

모든 파일 읽기 오류를 404로 바꾸지 않습니다

경로를 확인하면서 5편의 예제에서 보완할 부분도 있습니다.

당시 getPost()는 오류가 발생하면 모두 null을 반환했습니다.

catch {
  return null;
}

간단한 예제에서는 편하지만 배포 문제를 확인할 때는 원인을 숨길 수 있습니다.

다음 두 상황이 모두 글이 없는 것처럼 보이기 때문입니다.

실제로 파일이 없음
→ 404

파일은 있지만 읽기 권한이 없음
→ 404처럼 처리됨

권한 없이 파일을 읽는 예제를 실행했을 때는 다음과 같은 오류를 확인했습니다.

NotCapable: Requires read access to "./content/posts/example.md"

이를 파일 없음과 구분하려면 catch를 바꿉니다.

catch (error) {
  if (error instanceof Deno.errors.NotFound) {
    return null;
  }

  throw error;
}

이렇게 하면 존재하지 않는 글은 기존처럼 null로 처리하고, 권한이나 파싱 문제는 오류로 드러납니다.

배포 파일 자체가 빠졌다면 여전히 NotFound가 발생할 수 있습니다. 따라서 알려진 글 URL을 실제로 요청하는 확인 과정도 필요합니다.

현재 Fresh 기본 실행 명령의 -A는 모든 권한을 허용합니다. 이번 글은 공식 실행 구성을 유지합니다. 권한을 제한하려면 콘텐츠 읽기뿐 아니라 애플리케이션과 의존성이 요구하는 권한을 함께 확인해야 합니다. Deno Security and permissions

공개 URL은 실행 환경에서 정합니다

로컬에서 페이지가 잘 보이더라도 canonical URL이나 RSS 링크에 localhost가 남아 있으면 배포 설정이 끝난 것이 아닙니다.

5편에서는 SITE.url을 기준으로 글의 대표 URL을 만들었습니다.

const canonical =
  `${SITE.url}/posts/${data.slug}`;

이제 SITE.url의 원본을 환경 변수로 연결합니다.

다음 예제는 도메인 루트에 블로그를 배포하는 구성을 전제로 합니다.

utils/site.ts:

function readSiteUrl(
  value: string | undefined,
): string {
  if (!value) {
    throw new Error("SITE_URL is required");
  }

  const url = new URL(value);

  if (
    url.protocol !== "http:" &&
    url.protocol !== "https:"
  ) {
    throw new Error(
      "SITE_URL must use http or https",
    );
  }

  if (
    url.pathname !== "/" ||
    url.search ||
    url.hash ||
    url.username ||
    url.password
  ) {
    throw new Error(
      "SITE_URL must be an origin",
    );
  }

  return url.origin;
}

export const SITE = {
  name: "Fresh Notes",
  url: readSiteUrl(Deno.env.get("SITE_URL")),
  ogImage: "/og/default.png",
};

기존 SITE 객체에 다른 설정이 있다면 유지하고 URL을 읽는 부분만 연결합니다.

허용하는 값은 다음과 같습니다.

https://example.com
https://example.com/
http://localhost:8000

반환값에서는 끝의 /가 제거됩니다.

https://example.com/
→ https://example.com

환경 변수가 빠졌을 때는 바로 오류를 내도록 했습니다. 배포된 페이지가 잘못된 대표 URL을 조용히 생성하는 것보다 설정 오류를 먼저 발견하는 편이 낫기 때문입니다.

이 모듈은 서버에서 사용하는 설정입니다. Island에서 가져와 브라우저 설정처럼 사용하지 않습니다.

로컬 production 확인에서도 값을 지정합니다.

SITE_URL=http://localhost:8000 deno task build
SITE_URL=http://localhost:8000 deno task start

빌드 과정에서 서버 모듈이 평가되는 경우까지 고려해 빌드와 실행 양쪽에 같은 값을 전달합니다.

Dockerfile을 작성합니다

이번에는 최적화보다 파일 구성과 실행 조건을 쉽게 확인할 수 있는 단일 단계 이미지를 사용합니다.

프로젝트 루트에 Dockerfile을 만듭니다.

FROM denoland/deno:2.8.3

WORKDIR /app

RUN chown deno:deno /app

USER deno

COPY --chown=deno:deno . .

ARG GIT_REVISION
ARG SITE_URL

ENV DENO_DEPLOYMENT_ID=${GIT_REVISION}
ENV SITE_URL=${SITE_URL}

RUN test -n "$GIT_REVISION"
RUN test -n "$SITE_URL"

RUN deno install --frozen --allow-scripts
RUN deno task build

EXPOSE 8000

CMD ["deno", "serve", "-A", "--host=0.0.0.0", "--port=8000", "_fresh/server.js"]

예제의 Deno 버전은 2.8.3으로 고정했습니다. 프로젝트에서 검증한 버전이 다르다면 그 버전으로 맞춥니다. 이 값이 최신 버전이라는 뜻은 아닙니다.

구성에서 중요한 부분은 세 가지입니다.

WORKDIR /app
→ 콘텐츠 상대 경로의 기준 고정

COPY . .
→ 소스와 Markdown 콘텐츠를 함께 포함

컨테이너 안에서 설치와 빌드
→ 실행할 플랫폼에 맞는 의존성 준비

로컬의 node_modules를 컨테이너에 그대로 가져오지 않습니다. 운영체제나 CPU에 따라 달라지는 의존성이 있을 수 있으므로 컨테이너 안에서 설치합니다.

Deno 공식 이미지는 deno 사용자를 제공하며, Fresh 공식 문서도 Docker 안에서 의존성을 설치하고 빌드하는 구성을 안내합니다. Deno Docker image, Fresh Docker deployment

이 이미지는 빌드 도구와 소스도 포함하므로 크기를 최소화한 구성은 아닙니다. 여러 단계로 나눌 때는 _fresh뿐 아니라 런타임 콘텐츠와 필요한 의존성·캐시까지 함께 옮겨야 합니다.

배포 버전 식별자도 빌드에 전달합니다

Dockerfile에는 다음 환경 변수가 있습니다.

ENV DENO_DEPLOYMENT_ID=${GIT_REVISION}

Fresh 공식 Docker 안내는 현재 배포 버전을 식별하는 값으로 DENO_DEPLOYMENT_ID를 설정하고, 파일이 변경되면 이 값도 달라져야 한다고 설명합니다. Fresh Docker deployment

이번에는 Git 커밋 해시를 사용합니다.

따라서 빌드하기 전에 배포할 코드와 Markdown 파일을 커밋해두어야 합니다. 커밋하지 않은 파일까지 이미지에 넣으면서 같은 커밋 해시를 재사용하면 이미지 내용과 식별자가 어긋납니다.

코드 수정과 글 발행을 같은 배포 단위로 다루는 구조입니다.

소스와 Markdown 변경
        ↓
Git 커밋
        ↓
해당 커밋으로 이미지 빌드
        ↓
배포

.dockerignore에서 콘텐츠를 제외하지 않습니다

프로젝트 루트에 .dockerignore도 추가합니다.

.git
node_modules
_fresh
.deno_cache
coverage
.env
.env.*
*.log

로컬 빌드 결과와 의존성을 제외하고 이미지 안에서 다시 생성합니다.

환경 변수 파일도 복사 대상에서 제외합니다. 이번에 사용하는 SITE_URL은 공개 주소이지만, 같은 파일에 비밀 설정이 함께 들어갈 수 있으므로 .env 전체를 이미지에 넣는 방식은 사용하지 않습니다.

다음 항목은 제외하면 안 됩니다.

content/posts
content/pages
static
deno.json
deno.lock

특히 content/를 제외하면 코드의 빌드는 성공하더라도 런타임에 글을 읽지 못할 수 있습니다.

.dockerignore는 Docker 빌드에 전달할 파일을 결정합니다. Git에 포함된 파일인지와는 별개의 기준입니다. Docker build context

이미지를 빌드하고 실행합니다

배포할 변경을 커밋한 뒤 프로젝트 루트에서 실행합니다.

REVISION="$(git rev-parse HEAD)"
docker build \
  --build-arg GIT_REVISION="$REVISION" \
  --build-arg SITE_URL=https://example.com \
  -t "fresh-notes:$REVISION" \
  .

https://example.com은 실제 블로그의 공개 주소로 바꿉니다.

이미지를 로컬에서 확인할 때는 다음처럼 실행할 수 있습니다.

docker run --rm \
  --name fresh-notes-preview \
  -p 127.0.0.1:8000:8000 \
  "fresh-notes:$REVISION"

브라우저에서는 다음 주소로 접속합니다.

http://localhost:8000

접속 주소와 문서의 canonical URL이 다를 수 있습니다. 위 빌드는 공개 주소를 SITE_URL로 넣었기 때문에 로컬 접속에서도 canonical은 공개 주소를 가리킵니다.

컨테이너 호스팅에 배포할 때는 같은 이미지를 레지스트리에 올리고, 서비스가 컨테이너의 8000번 포트로 요청을 전달하도록 설정합니다.

이번 서버는 컨테이너 내부에서 HTTP로 실행합니다. 공개 HTTPS는 호스팅 플랫폼이나 앞단의 프록시에서 처리하는 구성을 전제로 합니다.

홈 화면 다음에는 글과 정적 파일을 확인합니다

홈이 열린다고 모든 경로가 정상인 것은 아닙니다.

배포 후에는 서로 다른 데이터 경로를 사용하는 요청을 확인합니다.

대상 확인할 내용
홈 공개 글 목록이 표시되는가
알려진 글 URL Markdown 본문과 코드 블록이 표시되는가
없는 글 URL HTTP 404를 반환하는가
검색 검색어와 결과가 정상적으로 표시되는가
About content/pages의 내용을 읽는가
CSS·Island JavaScript 실제 요청이 성공하는가
OG 이미지 이미지 응답을 반환하는가
sitemap·RSS 공개 도메인의 URL을 사용하는가

예를 들어 로컬 컨테이너의 글 응답은 다음처럼 확인할 수 있습니다.

curl -i \
  http://localhost:8000/posts/fresh-vite-deno-roles

존재하지 않는 글도 요청합니다.

curl -i \
  http://localhost:8000/posts/this-post-does-not-exist

브라우저에 오류 문구가 보이는지만 확인하지 말고 HTTP 상태 코드를 봅니다. 잘못된 URL에서 200을 반환하면 오류 페이지처럼 보여도 정상 문서로 처리될 수 있습니다.

검색은 URL 인코딩을 맡겨 요청할 수 있습니다.

curl -i -G \
  --data-urlencode "q=zero js" \
  http://localhost:8000/search

CSS와 Island JavaScript의 주소는 빌드에 따라 달라질 수 있으므로 브라우저 Network에서 실제 요청을 확인합니다.

초안은 상세 URL에서도 차단합니다

5편의 글 목록은 draft: true인 글을 제외했습니다.

하지만 목록에서 숨기는 것과 상세 URL 접근을 막는 것은 별개의 처리입니다.

상세 route에서 사용하는 getPost()가 초안까지 반환한다면, 파일 이름을 아는 요청은 글에 접근할 수 있습니다.

상세 route의 조건도 확인합니다.

const post = await getPost(ctx.params.slug);

if (!post || post.meta.draft) {
  return new Response(
    "Not found",
    { status: 404 },
  );
}

getPost() 내부에서 이미 초안을 제외한다면 route에서 중복 처리할 필요는 없습니다. 중요한 것은 상세 요청에서도 공개 여부를 검사한다는 점입니다.

확인용 초안을 하나 두고 다음 경로를 점검하면 됩니다.

홈 목록
→ 표시되지 않음

검색 결과
→ 표시되지 않음

sitemap과 RSS
→ 포함되지 않음

직접 상세 URL 요청
→ 404

이번 Docker 구성은 Markdown 파일 자체를 이미지에 포함합니다. 공개 여부는 콘텐츠 로더와 route가 책임집니다.

테마는 HTML 응답과 브라우저 동작을 나눠 확인합니다

8편에서 테마 선택을 쿠키로 저장하도록 바꿨습니다.

배포 후에는 쿠키를 포함한 요청의 첫 HTML도 확인합니다.

curl -i \
  -H "Cookie: fresh-notes-theme=freshblog-dark" \
  http://localhost:8000/

HTML에는 다음 값이 있어야 합니다.

<html lang="ko" data-theme="freshblog-dark">

8편의 middleware를 적용했다면 HTML 응답에는 다음 헤더도 있어야 합니다.

Cache-Control: private, no-store

이 확인은 서버가 쿠키를 읽어 HTML을 만드는 경로를 점검합니다.

실제 공개 HTTPS에서는 버튼 클릭 후 쿠키가 저장되는지, 다른 글로 이동해도 테마가 유지되는지 확인합니다.

Elements 패널에는 JavaScript가 변경한 DOM이 보입니다. 첫 응답의 테마를 확인할 때는 Network의 HTML Response를 사용합니다.

이번 글에서 검증한 범위

이번에는 작은 파일 읽기 예제로 다음 동작을 재현했습니다.

SITE_URL 검증 함수에서는 끝의 / 정규화와 잘못된 URL 거부 동작도 확인했습니다.

Dockerfile은 공식 Fresh와 Deno의 배포 구성을 바탕으로 작성했습니다. 이번 검증 환경에는 Docker가 없어 이미지 빌드와 원격 배포는 실행하지 않았습니다. 실제 프로젝트에서는 앞의 빌드 명령과 요청 확인까지 수행해야 합니다.

서버가 시작된다는 것, 콘텐츠를 읽는다는 것, 공개 도메인에서 기능이 동작한다는 것은 각각 확인해야 할 단계입니다.

배포 단위에는 코드와 콘텐츠가 함께 들어갑니다

Fresh Notes의 글은 Git에서 관리하는 Markdown 파일입니다.

따라서 새 글을 발행하는 과정도 애플리케이션 배포 흐름에 포함됩니다.

Markdown 작성
      ↓
공개 여부 확인
      ↓
코드와 콘텐츠 커밋
      ↓
의존성 설치와 production build
      ↓
배포
      ↓
글 URL과 실제 응답 확인

이 구조에서는 서버 안의 파일을 직접 수정하기보다 저장소를 원본으로 유지하는 편이 일관됩니다. 문제가 생겼을 때도 이전 이미지로 돌아가면 그 시점의 코드와 콘텐츠를 함께 복원할 수 있습니다.

Fresh의 빌드 명령 자체는 간단합니다. 배포에서 추가로 정해야 하는 것은 실행 폴더, 콘텐츠 파일의 배치, 공개 URL과 배포 버전입니다.

이 조건을 명시하고 실제 글 요청까지 확인하면, 개발 환경에서만 동작하는 블로그와 운영할 수 있는 블로그 사이의 차이를 줄일 수 있습니다.

참고 자료

#Fresh#Deno#Docker#Deployment#Markdown