MAATRIX / Блог / Astro в Docker Compose: готовый файл

Astro в Docker Compose: готовый файл

MAATRIX

Astro хорош тем, что по умолчанию отдаёт браузеру почти голый HTML — никакого гидратационного балласта, если вы сами его не попросили. Но как только доходит до деплоя, начинается привычная возня: какой Node ставить на сервер, куда девать node_modules, как не сломать сборку при обновлении зависимостей. Ниже — рабочий Dockerfile и два варианта docker-compose.yml, под статику и под SSR, которые можно скопировать и запустить без танцев с бубном.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →

Почему остров-архитектура упрощает контейнер

Astro по умолчанию рендерит страницы в статический HTML на этапе сборки, а JavaScript подключает точечно — только к тем компонентам, где явно указана директива вроде client:load или client:visible. Это называют «островной» архитектурой: интерактивные острова в океане статической разметки. Для Docker это не просто красивое слово — от него зависит, какой контейнер вам вообще нужен.

Есть два принципиально разных сценария:

  • Output: static — Astro на этапе сборки генерирует чистый HTML/CSS/JS в папку dist/. Рантайм не нужен вообще: контейнер — это просто nginx, раздающий файлы. Ноль Node-процессов в проде, минимальная поверхность атаки, копеечное потребление памяти.
  • Output: server или hybrid — часть страниц рендерится на каждый запрос (например, персонализированный контент или данные из БД). Тут нужен Node-адаптер (@astrojs/node) и постоянно работающий процесс — уже полноценный сервер внутри контейнера.

Смешивать эти два сценария в одном Dockerfile бессмысленно — они дают контейнеры разного веса и с разным жизненным циклом. Дальше — оба варианта по отдельности, определитесь заранее, какой ваш.

Dockerfile: multi-stage build

Общая часть для обоих сценариев — сборка. Разница только на последнем этапе. Вот multi-stage Dockerfile, который годится под оба output-режима (лишний этап для static просто выбрасывает Node из финального образа):

# ---- этап зависимостей ----
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# ---- этап сборки ----
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# ---- финальный этап: STATIC (nginx) ----
FROM nginx:1.27-alpine AS static
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s CMD wget -q --spider http://localhost/ || exit 1

# ---- финальный этап: SSR (node adapter) ----
FROM node:20-alpine AS ssr
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY package.json ./
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
HEALTHCHECK --interval=30s --timeout=3s CMD wget -q --spider http://localhost:4321/ || exit 1
CMD ["node", "./dist/server/entry.mjs"]

Выбираете нужную цель флагом --target при сборке: docker build --target static -t my-astro:static . или --target ssr -t my-astro:ssr .. Держать оба варианта в одном файле удобно — переключение между режимами занимает одну строку в CI, без правки самого Dockerfile.

Обратите внимание: npm ci, а не npm install — это гарантирует, что версии зависимостей в контейнере совпадают с package-lock.json и сборка не разъедется между машинами. Про то, зачем вообще нужен многоступенчатый build и что он экономит на размере образа, я подробнее разбирал в статье про оптимизацию Docker-образа через multi-stage build.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

docker-compose.yml для статического сайта

Если у вас output: 'static' в astro.config.mjs — берите этот вариант. Никакого Node в проде, только nginx:

services:
  astro-site:
    build:
      context: .
      target: static
    container_name: astro-static
    restart: unless-stopped
    ports:
      - "8080:80"
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://localhost/"]
      interval: 30s
      timeout: 3s
      retries: 3

И минимальный nginx.conf, который отдаёт статику и корректно обрабатывает клиентский роутинг (если он у вас есть — обычно у Astro его нет, страницы честно генерируются как отдельные HTML-файлы, но SPA-острова с client-side навигацией встречаются):

server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri $uri/index.html $uri.html =404;
    }

    location ~* \.(js|css|woff2?|png|jpg|jpeg|svg|ico)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    error_page 404 /404.html;
}

Этого контейнера достаточно для 95% Astro-проектов — блогов, лендингов, документации, портфолио. Он не ест память, стартует за секунды и не требует рестарта при скачках нагрузки.

docker-compose.yml для SSR-режима

Если часть страниц рендерится на запрос (output: 'server' или 'hybrid'), нужен адаптер @astrojs/node и постоянно живущий процесс:

services:
  astro-ssr:
    build:
      context: .
      target: ssr
    container_name: astro-ssr
    restart: unless-stopped
    environment:
      - HOST=0.0.0.0
      - PORT=4321
      - NODE_ENV=production
    ports:
      - "4321:4321"
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://localhost:4321/"]
      interval: 30s
      timeout: 3s
      retries: 3
    deploy:
      resources:
        limits:
          memory: 512M

Убедитесь, что адаптер установлен и настроен в astro.config.mjs:

import { defineConfig } from 'astro/config';
import node from '@astrojs/node';

export default defineConfig({
  output: 'server',
  adapter: node({
    mode: 'standalone',
  }),
});

Режим standalone — важная деталь: он поднимает собственный HTTP-сервер внутри Node-процесса, без внешнего Express. Именно его и запускает node ./dist/server/entry.mjs в Dockerfile выше. Режим middleware, для сравнения, годится только если вы встраиваете Astro в уже существующее Express-приложение — для контейнера «сам по себе» он не нужен.

Если у вас hybrid-режим (по умолчанию статика, но отдельные страницы помечены export const prerender = false), конфиг тот же самый — просто часть маршрутов Astro сам решит рендерить на лету, а часть отдаст как готовый HTML.

Reverse-proxy, HTTPS и несколько сайтов на сервере

Контейнер из примеров выше слушает свой порт на хосте (8080 или 4321), но напрямую наружу его лучше не выставлять — нужен reverse-proxy с TLS. Если на сервере один Astro-проект и вы хотите минимум конфигурации, Caddy закроет вопрос HTTPS автоматически:

example.com {
    reverse_proxy localhost:8080
}

Разворачивание Caddy с автоматическим Let's Encrypt я подробно описывал в статье про Caddy с авто-SSL на Ubuntu 24.04 — там же есть нюансы про DNS-валидацию, если у вас нестандартный CDN перед сервером.

Если сайтов несколько (например, статический Astro-блог и рядом SSR-приложение), удобнее собрать их в общий Docker Compose стек с одним reverse-proxy наверху — как настроить несколько проектов на одном VPS без конфликтов портов, я разбирал в статье про настройку нескольких сайтов на одном VPS. Общий compose-файл для прод-окружения с сетями, volumes и restart-политиками — тема отдельная, база по этому вопросу есть в статье про Docker Compose для продакшена.

Типичные проблемы при деплое

Контейнер стартует и сразу падает в SSR-режиме. Чаще всего причина — забытый HOST=0.0.0.0. Node по умолчанию слушает localhost, что внутри контейнера означает «только сам с собой», и наружу порт не пробрасывается, хотя docker ps покажет контейнер живым. Проверьте docker logs astro-ssr — если там тишина и контейнер просто не отвечает на пробросе порта, это оно.

Здоровый вроде бы контейнер отдаёт 502 через reverse-proxy. Обычно это гонка при старте: Caddy или nginx поднимаются раньше, чем Astro успел собрать SSR-сервер и начать слушать порт. Healthcheck из примеров выше решает это частично, но если проблема воспроизводится стабильно, добавьте depends_on с условием service_healthy в compose-файле верхнего уровня, который объединяет прокси и приложение.

Образ получается неожиданно тяжёлым. Проверьте, не тащит ли build-этап devDependencies в финальный слой — npm ci без флага --omit=dev в SSR-варианте установит вообще все зависимости, включая тестовые фреймворки и линтеры. Для SSR-этапа стоит либо ставить только прод-зависимости отдельным шагом, либо копировать в финальный образ не весь node_modules, а прогнать npm prune --production перед копированием.

Статические ассеты не кешируются браузером. Astro сам версионирует имена файлов при сборке (хэш в имени), так что Cache-Control: public, immutable из nginx-конфига выше безопасен — при следующем деплое имена файлов изменятся сами, и браузер запросит новые.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Нужен ли Node.js в контейнере, если сайт полностью статический?

Нет. При output: 'static' дело до Node в проде не доходит — HTML собирается на этапе docker build, а раздаёт готовые файлы nginx. Это и есть основная экономия ресурсов, ради которой стоит явно проверить свой astro.config.mjs.

Как быть с переменными окружения, которые нужны на этапе сборки (например, ключ API для генерации статики)?

Передавайте их через --build-arg в docker build и объявляйте ARG/ENV в Dockerfile до шага RUN npm run build. В compose-файле это соответствует секции build.args. Секреты этим способом лучше не передавать — build-arg попадает в историю слоёв образа.

Можно ли совместить static и SSR в одном контейнере, если часть страниц динамическая?

Технически можно через hybrid-режим и SSR-Dockerfile — Astro сам решит, что рендерить на лету, а что отдать как готовый HTML из сборки. Отдельный static-контейнер для этого не нужен.

Сколько ресурсов закладывать под SSR-контейнер?

Для небольшого проекта 512 МБ памяти с запасом достаточно — сам Node-процесс под Astro лёгкий, основной расход даёт логика ваших API-роутов и обращения к БД. Для static-варианта хватит и 128–256 МБ, там работает только nginx.

Что делать, если сборка Astro падает на этапе npm run build только внутри Docker, а локально всё работает?

Чаще всего расхождение версий Node — сверьте локальную версию с node:20-alpine в Dockerfile, а также проверьте, не используются ли нативные модули, которым нужны системные библиотеки, отсутствующие в Alpine (тогда переходите на node:20-slim).

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →