Проблема

Для инженерного блога нужен был контур, в котором статья это обычный файл рядом с кодом, а публикация не требует ни админки, ни базы. Отдельное требование – чтобы то, что я вижу локально, и то, что уезжает на сервер, собиралось одним и тем же кодом.

Сайт сделан при помощи Astro. Во время сборки Astro читает страницы, компоненты и Markdown-файлы, а затем записывает результат в каталог dist/. В production не нужен Node.js: nginx отдаёт уже собранные HTML, CSS и JavaScript.

Markdown + Astro components
           │
           ▼
      npm run build
           │
           ▼
     dist/ directory
           │
           ▼
      nginx :80

Node.js остаётся только на этапах разработки и сборки. Из этого следует всё остальное: два разных образа, переменные, которые нужно передать до сборки, а не после, и публикация через коммит.

Структура проекта

Контент, маршруты и инфраструктура находятся в одном репозитории:

.
├── src/
│   ├── components/          # Astro-компоненты страниц
│   ├── content/
│   │   └── writing/
│   │       ├── ru/          # статьи на русском
│   │       └── en/          # статьи на английском
│   ├── layouts/             # общий HTML-каркас
│   ├── pages/               # маршруты Astro
│   ├── styles/              # глобальные стили
│   └── content.config.ts    # схема frontmatter
├── public/                  # файлы без обработки Astro
├── tests/e2e/               # Playwright-тесты
├── Dockerfile               # production-сборка
├── Dockerfile.dev           # для разработки с hot reload
├── docker-compose.yml       # локальные сервисы
├── Makefile                 # короткие команды
└── package.json

Astro создаёт маршруты из src/pages/, а статьи загружает через Content Collections. Схема в src/content.config.ts проверяет обязательные поля до сборки, поэтому статья с ошибкой во frontmatter не попадёт в релиз незаметно: сборка падает, а не выкладывает страницу без даты или описания.

Production Dockerfile

Раз в проде нужен только результат сборки, образ собирается в два этапа. Первый устанавливает зависимости и запускает Astro, второй содержит только nginx и каталог dist/. Node.js и node_modules в финальный образ не попадают.

# syntax=docker/dockerfile:1.7
FROM node:24-alpine AS build

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY astro.config.mjs tsconfig.json ./
COPY src ./src
COPY public ./public

ARG SITE_URL=https://example.com
ENV SITE_URL=${SITE_URL} \
    ASTRO_TELEMETRY_DISABLED=1

RUN npm run build

FROM nginx:alpine AS runtime

COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html

EXPOSE 80

HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=5 \
  CMD wget --quiet --tries=1 --spider http://127.0.0.1/healthz || exit 1

SITE_URL объявлен как ARG, а не как переменная запуска, и это принципиально. Astro подставляет его в canonical URL и в sitemap прямо во время сборки, то есть значение запекается в статические файлы. Передать его через docker run -e уже поздно: страницы к этому моменту готовы.

Dockerfile.dev с hot reload

Для разработки nginx не нужен, нужен обратный набор: Node.js, dev-сервер и исходники, которые видны контейнеру. Поэтому нужен второй Dockerfile.

FROM node:24-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .

ENV SITE_URL=http://localhost:4321 \
    ASTRO_TELEMETRY_DISABLED=1

EXPOSE 4321

CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "4321"]

Здесь SITE_URL уже обычный ENV: локально canonical URL никого не волнует, а пересобирать образ ради смены адреса не хочется.

Docker Compose для разработки

Compose держит два сервиса из этих двух образов и решает три отдельные проблемы с файловой системой. Bind mount передаёт изменения исходников контейнеру. Отдельный volume не даёт хостовой папке перекрыть контейнерный node_modules. Каталог .astro изолирован в tmpfs, чтобы lock-файл dev-сервера с хоста не мешал повторному запуску контейнера.

services:
  dev:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "4321:4321"
    environment:
      SITE_URL: http://localhost:4321
    volumes:
      - .:/app
      - node_modules:/app/node_modules
    tmpfs:
      - /app/.astro

  preview:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        SITE_URL: http://localhost:8081
    ports:
      - "8081:80"

volumes:
  node_modules:

Запуск разработки:

docker compose up --build dev

После запуска сайт доступен на http://localhost:4321. Изменения в src/ видны без пересборки образа. Пересобрать dev-образ нужно после изменения package.json, package-lock.json или самого Dockerfile.dev.

Сервис preview собирает ровно тот образ, который поедет на сервер, и поднимает его локально:

docker compose up --build preview

Он доступен на http://localhost:8081. Это и есть ответ на требование про одинаковый код: проверить production-образ можно до того, как он попадёт на сервер.

Makefile с основными командами

Makefile не добавляет новой логики. Он хранит короткие имена для команд, которые легко забыть или набрать с ошибкой.

PORT ?= 4321
SITE_URL ?= http://localhost:$(PORT)

.PHONY: install run dev up preview check build down logs

install:
	npm ci

run:
	SITE_URL=$(SITE_URL) npm run dev -- --host 0.0.0.0 --port $(PORT)

dev:
	PORT=$(PORT) SITE_URL=$(SITE_URL) docker compose up --build dev

up:
	PORT=$(PORT) SITE_URL=$(SITE_URL) docker compose up --build -d dev

preview:
	docker compose up --build preview

check:
	npm run check
	npm run build

build:
	SITE_URL=$(SITE_URL) npm run build

logs:
	docker compose logs --tail=100 -f dev

down:
	docker compose down --remove-orphans

Повседневный цикл получается коротким:

make up
make logs
make check
make down

Как опубликовать новую статью

Создайте Markdown-файл в каталоге нужного языка. Например:

src/content/writing/ru/docker-healthcheck.md

Добавьте frontmatter и текст статьи:

---
locale: ru
translationKey: docker-healthcheck
routeSlug: docker-healthcheck
title: Как проверить состояние контейнера
description: Добавляем healthcheck и используем его в Docker Compose.
pubDate: 2026-09-01
dateLabel: 1 сентября 2026
readingTime: 5 мин
category: Docker
tags:
  - Docker
  - Operations
draft: true
---

Коротко объясните задачу и ожидаемый результат.

## Добавляем healthcheck

Опишите решение и добавьте проверяемый пример кода.

Пока draft: true, статья не появится в списке и production-сборке. Перед публикацией:

  1. закончите текст и замените draft на false;
  2. запустите make check;
  3. откройте /writing/docker-healthcheck локально;
  4. проверьте заголовок, описание, дату, листинги и мобильную версию;
  5. закоммитьте Markdown-файл и отправьте изменение в основную ветку.

Для английского перевода создайте файл в src/content/writing/en/. Используйте тот же translationKey, но переведите остальные поля. routeSlug может отличаться: он отвечает только за URL конкретного языка.

CI выполняет проверку Content Collections, собирает статические страницы и создаёт production-образ. После деплоя nginx начинает отдавать новую версию целиком. База данных, миграции и очистка кэша для публикации статьи не нужны.

Подводные камни

SITE_URL нельзя поменять после сборки. Это ARG, значение которого Astro запекает в canonical URL и sitemap. Если поднять готовый образ с другим -e SITE_URL, страницы останутся со старым адресом, и заметить это можно уже в поисковой выдаче.

--host 0.0.0.0 в dev-контейнере обязателен. Если оставить стандартный localhost внутри контейнера, порт будет опубликован, но браузер на хосте не сможет подключиться к Astro. Симптом выглядит как проблема сети или проброса портов, хотя дело в одном флаге.

Lock-файл .astro переживает контейнер. Если каталог попадает на хост через тот же bind mount, повторный запуск упирается в замок от предыдущего процесса. tmpfs решает это тем, что каталог просто не существует за пределами жизни контейнера.

Публикация это коммит и прогон CI. Редактору без гита сюда не зайти, а данным, которые меняются чаще деплоя, здесь негде жить. Для блога это приемлемая цена, для сайта с ежедневными правками контента – уже нет.

Выводы

  • Разделение на два образа не дублирование, а следствие того, что в production не нужен Node.js. Production-образ содержит nginx и dist/, dev-образ – зависимости и dev-сервер; общего у них только исходники.
  • Три volume в Compose решают три разные задачи, и ни один нельзя убрать как избыточный: bind mount даёт hot reload, именованный volume защищает node_modules, tmpfs снимает конфликт с lock-файлом.
  • Схема Content Collections – это тест на контент. Ошибка во frontmatter роняет сборку до деплоя, а не превращается в страницу без описания.
  • Цена подхода в том, что публикация проходит через гит и CI. Статья остаётся обычным файлом.