Проблема
Для инженерного блога нужен был контур, в котором статья это обычный файл рядом с кодом, а публикация не требует ни админки, ни базы. Отдельное требование – чтобы то, что я вижу локально, и то, что уезжает на сервер, собиралось одним и тем же кодом.
Сайт сделан при помощи 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 /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK \
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-сборке.
Перед публикацией:
- закончите текст и замените
draftнаfalse; - запустите
make check; - откройте
/writing/docker-healthcheckлокально; - проверьте заголовок, описание, дату, листинги и мобильную версию;
- закоммитьте 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. Статья остаётся обычным файлом.