Astro в Docker Compose: готовый файл
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 ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →