MAATRIX / Блог / Eleventy (11ty) в Docker Compose: готовый файл

Eleventy (11ty) в Docker Compose: готовый файл

MAATRIX

Eleventy хорош тем, что не тащит за собой ничего лишнего — ни виртуального DOM, ни рантайм-фреймворка, ни обязательного React. Но именно поэтому вокруг него меньше готовых рецептов деплоя, чем вокруг Next.js или Astro: разработчики привыкли гонять npx @11ty/eleventy локально и заливать _site на статический хостинг руками. Если вам нужен воспроизводимый билд на сервере — с одинаковым результатом у вас на ноутбуке, в CI и на проде — Docker Compose закрывает этот вопрос за один файл. Ниже — рабочая связка: контейнер сборки на Node.js и контейнер раздачи на Nginx, плюс отдельный режим для разработки с live-reload.

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

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

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

Почему Eleventy вообще просит Docker

Eleventy (11ty) — генератор статики без обязательного клиентского JS-фреймворка: он берёт шаблоны (Nunjucks, Liquid, Markdown, EJS, JS) и превращает их в чистый HTML. Сама по себе статика докера не требует — можно выгрузить _site куда угодно. Но на практике у вас почти всегда есть три задачи, где контейнер экономит время:

  • Фиксация версии Node.js. 11ty капризен к версии Node — переход с 18 на 20 иногда меняет поведение некоторых плагинов (особенно связанных с изображениями через @11ty/eleventy-img, который тянет sharp). Docker гарантирует, что на сервере та же версия, что и в CI.
  • Единый пайплайн сборка → раздача. Вы получаете один docker-compose.yml, который одинаково работает локально (docker compose up) и на сервере (docker compose up -d --build).
  • Изоляция от системного Node. Не нужно ставить nvm на хост, следить за глобальными пакетами и чистить кэш npm вручную.

Если у вас уже есть Nginx на сервере и хочется без Docker — почитайте Caddy или Nginx: что выбрать для сервера, там разбор для случаев, когда контейнеризация избыточна. Но если вы уже держите на сервере несколько сайтов в контейнерах — этот подход впишется в общую схему естественно.

Архитектура: multi-stage сборка + Nginx

Правильный паттерн для статики — двухэтапная (multi-stage) сборка Dockerfile: на первом этапе Node.js собирает _site, на втором — лёгкий образ Nginx раздаёт готовые файлы. Так продакшен-контейнер не тащит за собой ни Node, ни node_modules, ни исходники — только HTML/CSS/JS и веб-сервер.

project/
├── docker-compose.yml
├── Dockerfile
├── nginx.conf
├── package.json
├── .eleventy.js
├── src/
│   ├── index.md
│   ├── _includes/
│   └── ...
└── .dockerignore

Dockerfile:

# ---- Этап 1: сборка ----
FROM node:20-alpine AS build
WORKDIR /app

COPY package.json package-lock.json* ./
RUN npm ci

COPY . .
RUN npx @11ty/eleventy

# ---- Этап 2: раздача ----
FROM nginx:1.27-alpine AS runtime
COPY --from=build /app/_site /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

.dockerignore, чтобы не тащить в контекст сборки лишнее и не раздувать образ:

node_modules
_site
.git
.env
npm-debug.log

Обратите внимание на npm ci вместо npm install — это важно для воспроизводимости: ci строго следует package-lock.json и падает, если лок-файл рассинхронизирован с package.json, вместо того чтобы молча что-то пересчитать.

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

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

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

docker-compose.yml для продакшена

services:
  eleventy-site:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: eleventy-site
    restart: unless-stopped
    ports:
      - "8080:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    networks:
      - web

networks:
  web:
    external: true

Если сайт единственный на сервере, сеть web можно не выносить во внешнюю и оставить дефолтную — external: true нужен, когда перед этим контейнером стоит общий реверс-прокси (Traefik, тот же Nginx на хосте, или Caddy), который проксирует несколько сайтов на один сервер по доменам.

nginx.conf — минимальный, но с корректной обработкой кэша и чистых URL (Eleventy по умолчанию генерирует about/index.html для /about/):

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

    # Чистые URL: /about/ -> /about/index.html
    location / {
        try_files $uri $uri/ $uri.html =404;
    }

    # Статика с длинным кэшем — хешируйте имена файлов в сборке,
    # если используете эту схему кэширования
    location ~* \.(css|js|woff2?|svg|png|jpg|jpeg|gif|ico)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    # HTML не кэшируем агрессивно — контент меняется чаще
    location ~* \.html$ {
        expires 5m;
        add_header Cache-Control "public, must-revalidate";
    }

    error_page 404 /404.html;
}

Запуск:

docker compose up -d --build

Сборка займёт от нескольких секунд до минуты в зависимости от количества страниц и наличия обработки изображений через eleventy-img.

Режим разработки: live-reload в контейнере

Для локальной разработки продакшен-сборка неудобна — нужен watch-режим со встроенным dev-сервером Eleventy (он поднимает Browsersync на 8080 порту и WebSocket на 35729 для live-reload). Отдельный docker-compose.dev.yml:

services:
  eleventy-dev:
    image: node:20-alpine
    container_name: eleventy-dev
    working_dir: /app
    command: sh -c "npm ci && npx @11ty/eleventy --serve --watch"
    volumes:
      - .:/app
      - /app/node_modules
    ports:
      - "8080:8080"
      - "35729:35729"
    environment:
      - NODE_ENV=development

Ключевой момент — анонимный volume /app/node_modules поверх примонтированного .:/app. Без него node_modules, установленные внутри контейнера при npm ci, будут перезатёрты пустой (или собранной под чужую платформу) директорией с хоста, и сборка сломается непонятной ошибкой про отсутствующие бинарники — особенно с sharp, у которого нативные модули под конкретную платформу.

Запуск дев-режима:

docker compose -f docker-compose.dev.yml up

Откройте http://localhost:8080 — изменения в src/ подхватываются автоматически, браузер перезагружается сам.

Переменные окружения и конфигурация Eleventy

Если сайт использует переменные окружения на этапе сборки (например, базовый URL для sitemap или API-ключ для сборки данных из внешнего источника), прокидывайте их через build.args в Dockerfile, а не через рантайм-environment — Nginx-контейнер их всё равно не увидит, файлы уже собраны:

services:
  eleventy-site:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        SITE_URL: https://example.com
    # ...

В Dockerfile:

ARG SITE_URL
ENV SITE_URL=${SITE_URL}
RUN npx @11ty/eleventy

И в .eleventy.js или в шаблонах читайте process.env.SITE_URL. Не кладите секреты (API-ключи третьих сервисов) в build args, если образ будет публиковаться в общий registry — они остаются в слоях образа и извлекаемы через docker history. Для приватного сервера, где образ никуда не публикуется, это не критично, но привычку лучше не заводить.

Обновление контента без пересборки: cron + git pull

Частый сценарий — контент правится в Markdown-файлах через git (например, коллеги коммитят статьи), и хочется автоматического пересобрания без ручного захода на сервер. Простой вариант — cron-джоба на хосте, которая тянет изменения и пересобирает образ:

#!/bin/bash
# /opt/scripts/eleventy-rebuild.sh
cd /opt/eleventy-site || exit 1
git pull origin main
docker compose up -d --build eleventy-site
# crontab -e
*/10 * * * * /opt/scripts/eleventy-rebuild.sh >> /var/log/eleventy-rebuild.log 2>&1

Это не самый элегантный подход (пересборка каждые 10 минут даже без изменений — лишняя нагрузка), но для небольшого блога или лендинга он проще, чем поднимать webhook-приёмник. Если контента много и пересборка занимает заметное время, разумнее дёргать скрипт по git-webhook, а не по расписанию — но это уже отдельная инфраструктура, сравнимая по сложности с полноценным CI.

Для более серьёзных проектов на 11ty часто смотрят в сторону готовых генераторов вроде Hugo или Gatsby — если у вас встал вопрос выбора, полезно сравнить подходы в статьях про установку и деплой Hugo и деплой Gatsby-сайта на VPS: архитектура сборки у них похожая, разница в основном в экосистеме плагинов и скорости билда на больших объёмах контента.

HTTPS и продакшен-раздача

Контейнер из этой схемы отдаёт HTTP на порту 8080 хоста — это осознанно: TLS-терминацию удобнее выносить на отдельный реверс-прокси перед всеми сайтами сервера, а не настраивать сертификаты в каждом контейнере отдельно. Если на сервере уже стоит Nginx или Caddy на хосте, добавьте виртуальный хост, проксирующий на 127.0.0.1:8080:

server {
    listen 443 ssl http2;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

Про получение сертификата и выбор между Certbot и acme.sh — отдельный разбор в статье Certbot или acme.sh: что выбрать для сервера. Если сайт отдаёт много статики и трафик заметный, также стоит посмотреть настройку CDN для сайта — для чисто статического контента CDN даёт особенно заметный эффект, потому что кэшировать можно практически всё.

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

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

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

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

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

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

Почему сборка Eleventy в Docker падает на sharp (обработка изображений)?

sharp использует нативные бинарники под конкретную архитектуру и платформу. Если вы собираете образ на Mac с Apple Silicon для сервера на amd64, нужен либо docker buildx build --platform linux/amd64, либо просто собирать образ прямо на сервере, а не пушить локально собранный. Также не забывайте про анонимный volume для node_modules в dev-режиме — без него бинарники с одной платформы попадают в контейнер с другой.

Можно ли обойтись без Nginx и раздавать статику прямо из Node-контейнера?

Технически да, через eleventy --serve, но это dev-сервер, не рассчитанный на продакшен-нагрузку: нет нормального кэширования заголовков, gzip/brotli-сжатия и обработки конкурентных соединений на уровне, который даёт Nginx. Для продакшена multi-stage сборка с Nginx на выходе — правильный путь.

Как быть с формами и другой динамикой на статическом сайте?

Eleventy сам ничего не обрабатывает на сервере — для форм нужен либо сторонний сервис (Formspree и аналоги), либо свой маленький бэкенд рядом, который можно добавить отдельным сервисом в тот же docker-compose.yml и подключить к общей сети web.

Нужен ли отдельный volume для _site в продакшен-компоузе?

Нет, и лучше не заводить — _site копируется внутрь образа на этапе COPY --from=build, так что при пересборке образа файлы всегда актуальны. Volume здесь скорее источник путаницы: если примонтировать хостовую директорию поверх _site, есть риск раздавать устаревшую версию.

Как ускорить повторные сборки, если node_modules не меняются?

Порядок инструкций в Dockerfile уже оптимален: COPY package.json package-lock.json* ./ и RUN npm ci идут до COPY . ., поэтому Docker переиспользует закэшированный слой с зависимостями, пока не поменялся именно лок-файл — сама структура сайта пересобирается быстро.

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

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

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