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

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

MAATRIX

Gatsby генерирует статику, но собирать сайт вручную на сервере каждый раз, следить за версией Node и вручную настраивать Nginx — трата времени. Docker Compose решает это одним файлом: контейнер собирает проект, второй раздаёт готовые файлы, и весь процесс воспроизводится на любом сервере командой docker compose up.

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

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

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

Почему Gatsby — не совсем обычная статика

Gatsby не просто рендерит Markdown в HTML, как Hugo или Jekyll. Это React-приложение, которое на этапе сборки (gatsby build) обходит GraphQL-слой данных, тянет контент из источников (файлы, CMS, API через плагины) и генерирует статические HTML-файлы с гидратацией на клиенте. Отсюда два практических следствия для контейнеризации.

Во-первых, сборка требует Node.js и может съедать заметно больше памяти и времени, чем сборка того же объёма контента на Hugo — GraphQL-кэш и обработка изображений через gatsby-plugin-image тяжелее для CPU. На слабых VPS (1 vCPU / 1 GB RAM) сборка среднего блога может занимать несколько минут, а на проекте с сотнями страниц и обработкой изображений — заметно дольше; точные цифры зависят от объёма контента и мощности сервера, ориентируйтесь на свои тесты.

Во-вторых, готовый результат — это просто папка public/ со статическими файлами. Runtime-контейнер с Node здесь не нужен вообще: раздавать статику должен веб-сервер вроде Nginx, а не gatsby serve, который предназначен только для локальной проверки сборки.

Из этого и вытекает архитектура: multi-stage сборка, где Node используется только на этапе build, а в продакшене работает лёгкий контейнер с Nginx.

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

Предполагаем стандартный layout Gatsby-проекта:

gatsby-site/
├── src/
├── static/
├── gatsby-config.js
├── gatsby-node.js
├── package.json
├── package-lock.json
├── Dockerfile
├── docker-compose.yml
└── nginx.conf

Если проекта пока нет, создать каркас можно локально командой npx gatsby new gatsby-site и уже готовую папку загрузить на сервер — сам процесс инициализации Gatsby внутри контейнера возможен, но избыточен для регулярного деплоя.

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

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

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

Dockerfile: multi-stage сборка

Ключевая идея — два этапа. Первый собирает статику и умирает вместе с образом-builder, второй содержит только результат и Nginx:

# --- Этап 1: сборка ---
FROM node:20-bookworm-slim AS builder

WORKDIR /app

# Кэшируем установку зависимостей отдельным слоем
COPY package.json package-lock.json ./
RUN npm ci

COPY . .

# Переменные окружения на этапе сборки (при необходимости)
ARG GATSBY_API_URL
ENV GATSBY_API_URL=${GATSBY_API_URL}

RUN npm run build

# --- Этап 2: раздача статики ---
FROM nginx:1.27-alpine AS runner

COPY --from=builder /app/public /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
  CMD wget -qO- http://localhost/ || exit 1

Важный нюанс: npm ci (а не npm install) использует package-lock.json строго и не модифицирует его — это обязательно для воспроизводимых сборок в CI и на сервере. Слой с COPY package.json package-lock.json идёт до COPY . ., чтобы Docker кэшировал npm ci и не переустанавливал зависимости при каждом изменении исходников — это экономит минуты на пересборке.

Если сайт использует gatsby-plugin-sharp для обработки изображений, на некоторых версиях Alpine возникают проблемы с нативными зависимостями (libvips) — при сборке лучше использовать node:20-bookworm-slim, а не node:20-alpine, чтобы не отлаживать бинарную совместимость.

nginx.conf: раздача SPA-подобной статики

Gatsby генерирует отдельные HTML-файлы для каждого маршрута, но клиентский роутинг (переходы между страницами без перезагрузки) всё равно нужно поддержать, плюс корректно отдать кастомную страницу 404:

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

    # Gatsby сам генерирует статические HTML для каждого пути,
    # поэтому основной поиск идёт по файлу, а не по SPA-фоллбэку
    location / {
        try_files $uri $uri/index.html $uri.html /404.html;
    }

    # Хешированные ассеты (JS/CSS) — кэш навсегда, имя файла меняется при пересборке
    location /static/ {
        add_header Cache-Control "public, max-age=31536000, immutable";
        access_log off;
    }

    location ~* \.(js|css|woff2?|svg|png|jpg|jpeg|webp|avif)$ {
        add_header Cache-Control "public, max-age=31536000, immutable";
        access_log off;
    }

    # HTML-документы не кэшируем агрессивно — контент может обновиться
    location ~* \.html$ {
        add_header Cache-Control "public, max-age=0, must-revalidate";
    }

    gzip on;
    gzip_types text/plain text/css application/javascript application/json image/svg+xml;
    gzip_min_length 1024;

    error_page 404 /404.html;
}

Разница в кэшировании принципиальна: файлы в page-data/, static/ и хешированные JS/CSS-бандлы Gatsby можно кэшировать на год, потому что при каждой сборке у них меняется хеш в имени. А сами HTML-страницы должны обновляться сразу — иначе браузер покажет пользователю старую версию контента даже после деплоя.

docker-compose.yml

Сам файл получается компактным, поскольку вся сложность уже в Dockerfile:

services:
  gatsby-site:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        GATSBY_API_URL: ${GATSBY_API_URL:-}
    image: gatsby-site:latest
    container_name: gatsby-site
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:80"
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost/"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s

Порт намеренно опубликован только на 127.0.0.1 — наружу сайт смотрит через внешний Nginx или Traefik, который терминирует TLS и проксирует запросы на этот внутренний порт. Так на одном сервере можно держать несколько сайтов за общим реверс-прокси, не занимая 80/443 напрямую контейнером Gatsby. Если у вас уже настроен Nginx как реверс-прокси, добавить туда ещё один server_name с proxy_pass http://127.0.0.1:8080 — вопрос пяти минут.

Переменную GATSBY_API_URL в примере используйте, только если сайт реально ходит за данными во внешний API на этапе сборки (например, headless CMS); для чисто файлового контента (Markdown в репозитории) секция args не нужна вообще.

Первая сборка и деплой

Порядок действий на чистом сервере:

# Устанавливаем Docker и Compose, если их ещё нет
curl -fsSL https://get.docker.com | sh

# Клонируем проект (или загружаем через rsync/scp)
git clone https://github.com/yourname/gatsby-site.git
cd gatsby-site

# Собираем образ и поднимаем контейнер
docker compose build --no-cache
docker compose up -d

# Проверяем логи сборки, если что-то пошло не так
docker compose logs -f

Флаг --no-cache полезен на первой сборке или после серьёзных изменений в зависимостях — гарантирует чистую установку. В повседневных пересборках его можно убрать: Docker сам переиспользует кэшированные слои, если package-lock.json не менялся.

Для повторного деплоя после изменений в контенте или коде:

git pull
docker compose build
docker compose up -d --force-recreate

--force-recreate пересоздаёт контейнер даже если Compose считает, что конфигурация не изменилась (актуально, когда меняется только содержимое образа, а не docker-compose.yml).

Автоматизация: пересборка при изменении контента

Ручной git pull && docker compose up -d --build после каждой правки контента быстро надоедает. Рабочий вариант — GitHub Actions, который по пушу в main подключается к серверу по SSH и пересобирает контейнер:

# .github/workflows/deploy.yml
name: Deploy Gatsby site

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1.0.0
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: |
            cd /opt/gatsby-site
            git pull origin main
            docker compose build
            docker compose up -d --force-recreate

Секреты (SERVER_HOST, SERVER_USER, SERVER_SSH_KEY) хранятся в настройках репозитория GitHub, а не в коде. Если предпочитаете self-hosted CI без внешних сервисов, тот же сценарий реализуется через GitLab CI/CD на своём VPS.

Для сайтов на headless CMS (Contentful, Strapi, DatoCMS) вместо git push триггером обычно служит вебхук от CMS — он вызывает тот же пересборочный скрипт через отдельный обработчик или сразу дёргает GitHub Actions через repository_dispatch.

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

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

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

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

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

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

Почему нельзя просто запустить gatsby serve в контейнере вместо Nginx?

Технически можно, но gatsby serve — это Node-процесс, который держит в памяти весь Node.js runtime ради раздачи статических файлов. Nginx на Alpine делает то же самое в разы экономнее по памяти и CPU, плюс умеет gzip, кэш-заголовки и TLS без дополнительных пакетов.

Сборка падает с нехваткой памяти на маленьком VPS.

Добавьте своп (2 GB обычно достаточно для среднего Gatsby-сайта) или временно увеличьте лимит Node через NODE_OPTIONS=--max-old-space-size=2048 в ENV перед RUN npm run build. Долгосрочно — соберите сайт на более мощном сервере, а раздавать статику можно на минимальном.

Как передать переменные окружения времени выполнения (не сборки)?

Gatsby встраивает переменные с префиксом GATSBY_ в бандл на этапе сборки — это статика, менять её после npm run build нельзя. Если нужна конфигурация, которая различается между окружениями без пересборки, используйте env.js-файл, который подгружается отдельным <script> и читается на клиенте, либо просто пересобирайте образ под каждое окружение.

Нужен ли отдельный volume для node_modules или кэша Gatsby?

Для одноразовой сборки внутри multi-stage образа — нет, кэш .cache/ и node_modules builder-этапа удаляются вместе с промежуточным слоем. Если хотите ускорить повторные CI-сборки, кэшируйте .cache/ через слои Docker BuildKit (--mount=type=cache) или через кэш самого CI-раннера.

Как быть с gatsby-plugin-image и обработкой сотен изображений при каждой сборке?

Это самая частая причина долгой сборки. Держите оригиналы изображений вне репозитория (в S3-совместимом хранилище или CDN) и подключайте через gatsby-source-filesystem с внешним URL, либо кэшируйте .cache/caches-lock.json и .cache/webpack между сборками через BuildKit cache mounts — это может ощутимо сократить время повторной сборки, но конкретный выигрыш зависит от объёма графики.

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

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

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