Traefik: ваш прокcи для веб-приложений Docker
linux веб-сервер Arch Linux Docker Symfony Traefik reverse proxy edge router FrankenPHP Debian
Что такое Traefik? Сами себя они позиционируют как Edge Router или Application Proxy. Суть в том, чтобы связать внешний запрос с конкретным сервисом, который его обслужит. В сочетании с Докером задача обнаружения сервисов и масштабирования решается весьма элегантно за счет меток. Из коробки поддерживается Let's Encrypt, а что не поддерживается, так это PHP-FPM: приложения должны сами предоставлять веб-сервер.
Установка
Как это ни странно, программа, предназначенная в немалой степени для работы с Docker, поставляется в его образе. Таким образом, вам нужна по сути любая система с установленным Докером, например мой новый фаворит – Arch Linux. Создание виртуальной машины VirtualBox я описал в соответствующей статье, в аналогичной я и буду работать далее.
В той статье я не создавал пользователей, давайте сделаем какого-нибудь dev'а:
useradd -m -G docker dev
passwd dev
mkdir /srv/docker
chown dev:dev /srv/docker
Я бессовестным образом добавил его в группу docker, чтобы не надо было каждый раз использовать sudo
(которого я даже и не установил), ну и папку создал. Вообще может вместо обычной директории стоило бы создать подтом btrfs, но не суть.
Перезаходим под новым пользователем и создаем к примеру /srv/docker/traefik/compose.yaml как по учебнику:
services:
traefik:
image: traefik:v3.4
restart: unless-stopped
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--entrypoints.web.address=:80"
ports:
- "80:80"
- "8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
Я правда restart
еще добавил. Запускаем:
docker compose up -d
И по идее уже можно зайти в панель управления «по IP» на порт 8080, например http://192.168.56.103:8080/
Недостатком такой конфигурации является обнаружение Трафиком лишних сервисов, поэтому я предпочитаю явно включать нужные. Поменяем параметр командной строки --providers.docker=true
на --providers.docker.exposedbydefault=false
и перезапустим приложение. На скриншоте выше вы можете видеть 4 сервиса, теперь их станет 3 (Трафик не создаст сервис для самого себя).
Также хотелось бы обратить внимание на то, что API (и, как следствие, панель управления) не защищены (--api.insecure=true
), что и позволяет обращаться к 8080-му порту. В виртуальной машине это не имеет значения и даже удобно, но на «продуктовом» сервере API нужно или отключать совсем, или защищать к примеру базовой HTTP-аутентификацией (об этом позже).
В документации предполагают единый compose-файл. Если же у нас несколько приложений, то без настройки сети Traefik не получит доступ к сервисам. Простейшее решение – перевести сеть Traefik'а в режим хоста. Проброс портов в таком случае не требуется.
services:
traefik:
image: traefik:v3.4
restart: unless-stopped
network_mode: host
command:
- "--api.insecure=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
Хотя такой трюк наверное не стоит повторять на боевом сервере.
Демо-приложение
Давайте развернем какой-нибудь «Hello World» на современном фреймворке – пусть будет Symfony. На данный момент – версия 7.x для PHP 8.2. В принципе у них есть свой образ с FrankenPHP и PostgreSQL (что весьма круто само по себе), но поскольку для демки вторая программа не нужна, сделаем все сами на базе первой.
Упомянутый FrankenPHP есть ни что иное, как Caddy (о которой я уже писал неоднократно, например в качестве веб-сервера на FreeBSD) со встроенной поддержкой PHP, что, по заверениям разработчиков, выводит производительность на космический уровень. Не то, чтобы я жаловался на скорость PHP-FPM…
Создадим директорию, например /srv/docker/demo. Dockerfile:
FROM dunglas/frankenphp
# Install PHP extensions
RUN install-php-extensions \
intl
# Install composer and unzip
RUN set -eux; \
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer; \
# smoke test
composer --version; \
apt-get update; \
apt-get install -y --no-install-recommends \
unzip \
; \
rm -rf /var/lib/apt/lists/*
# Use the default production configuration
RUN cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
На момент написания статьи у нас уже FrankenPHP v1.7.0 и PHP 8.4.10. Почему-то в комплекте не идет расширение intl – доустановил. Также решил «затащить» в сборку composer, которым мы и развернем проект, и unzip ему в помощь. Наконец, подкидываем «боевой» php.ini.
compose.yaml:
services:
php:
build: .
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.http.routers.demo.rule=PathPrefix(`/`)
environment:
CADDY_GLOBAL_OPTIONS: "auto_https off"
SERVER_NAME: "http://"
volumes:
# - ./app:/app
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
Меткой traefik.http.routers.demo.rule=PathPrefix(`/`)
мы создали (объявили) HTTP-роутер с правилом роутинга по префиксу пути, т.е. в случае HTTP-запроса, если путь начинается с /
(а это все возможные пути в URL), перенаправляем этот запрос данному сервису.
Забегая вперед, схему можно проиллюстрировать скриншотами админ-панели Traefik. Вот только зачем нужен такой здоровый промежуток между верхними и нижними блоками?..
В данном случае Docker назначил контейнеру IP 172.20.0.2. Любопытно, что не потребовалось указывать порт:
If a container exposes a single port, then Traefik uses this port.
If a container exposes multiple ports, then Traefik uses the lowest port. E.g. if80
and8080
are exposed, Traefik will use80
.
На заметку:
If a container does not expose any port, or the selection from multiple ports does not fit, then you must manually specify which port Traefik should use for communication by using the label traefik.http.services.<service_name>.loadbalancer.server.port
Возвращаемся к разбору compose.yaml. Следующая секция – переменные окружения:
environment:
CADDY_GLOBAL_OPTIONS: "auto_https off"
SERVER_NAME: "http://"
Ими я бессовестным образом отключаю чуть ли не главную фишку Caddy – автоматический HTTPS (этим в дальнейшем займется сам Traefik), и, как следствие, перевожу веб-сервер на обслуживание всего поступающего http-трафика. В образе dunglas/frankenphp предусмотрена гибкая настройка переменными окружения, так что в большинстве случаев собственный Caddyfile может и не потребоваться.
Пока что я закомментировал проброс директории, но сразу же прописал хранение внутренних данных и конфигурации в соответствии с общей инструкцией. Запустим:
docker compose up -d
И через некоторое время можно будет зайти на виртуальную машину, например http://192.168.56.103/, и увидеть сведения о PHP от «Франкенштейна».
Собственно, к приложению. Погасим сервис (docker compose down
), раскомментируем проброс директории и запустим обратно. Зайдем в контейнер, например:
docker exec -it demo-php-1 bash
Где demo-php-1
- автоматически назначенное имя контейнера. Разворачиваем приложение:
cd /
composer create-project symfony/symfony-demo app
cd app
openssl rand -hex 32 | php bin/console secrets:set APP_SECRET -
exit
По идее composer должен отработать гладко (здесь у нас не FreeBSD или Alpine Linux какой), а далее мы хитрой командой генерируем ключ приложения (без него не работает). Есть контакт!
Масштабирование
Это неотъемлемая часть любой статьи про Traefik – придется делать. Создадим, к примеру, 3 реплики нашего сервиса.
services:
php:
deploy:
replicas: 3
# остальные настройки...
down/up…
[+] Running 4/4
✔ Network demo_default Created 0.0s
✔ Container demo-php-3 Started 0.6s
✔ Container demo-php-1 Started 0.4s
✔ Container demo-php-2 Started 1.0s
В панели разработчика Symfony можно вызвать просмотр информации о PHP (/_profiler/phpinfo). Если спуститься к разделу Environment и несколько раз обновить страницу, то HOSTNAME
будет изменяться.
Сессии (авторизация)
Все бы ничего, но ломается авторизация (если не взводить флаг «Оставаться в системе»).
К счастью, починить легко – включаем «липкие» (sticky) сессии / куки.
- traefik.http.services.demo.loadbalancer.sticky.cookie=true
При желании можно установить имя этой самой «липкой печеньки», но особого смысла в этом я не вижу. Первоначально я подумал, что нужно перечислить те cookie, которые ставит сайт, но нет – это всего лишь служебная кука для самого Traefik, на основе которой он сопоставляет посетителя с конкретным контейнером.
Демо Symfony ставит куки с признаками HttpOnly и SameSite:"Lax", можем определить те же свойства:
- traefik.http.services.demo.loadbalancer.sticky.cookie.httponly=true
- traefik.http.services.demo.loadbalancer.sticky.cookie.samesite=lax
URL без index.php
Вот она – истинная цель установки Traefik! А не эти ваши обнаружения сервисов с масштабированием.
В чем смысл. Понятно, что так скорее всего никто не будет делать, но если явно прописать index.php в URL (http://192.168.56.103/index.php), то он там так и останется. Это с точки зрения SEO (на локалхосте ) как бы не есть хорошо. Добавляем промежуточное «ПО» (назвал
rmindexphp
) для редиректа по регулярке такими метками (в compose.yaml демки):
- traefik.http.routers.demo.middlewares=rmindexphp
- traefik.http.middlewares.rmindexphp.redirectregex.regex=/index\.php(?:/(.*)|$)
- traefik.http.middlewares.rmindexphp.redirectregex.replacement=/$${1}
В последней метке двойной знак $
для экранирования. Регулярка - «апачевская» /index\.php(?:/(.*)|$)
(только начинается со слэша вместо символа начала строки). Стало быть:
(?:
– объявление группы без «захвата»- 1-я альтернатива:
- литерал
/
(.*)
захватывает любые символы после слэша
- литерал
- 2-я альтернатива:
$
– конец строки
- 1-я альтернатива:
)
– конец группы
Таким образом:
URL | Редирект |
---|---|
http://192.168.56.103/index.php | http://192.168.56.103/ |
http://192.168.56.103/index.php/ | http://192.168.56.103/ |
http://192.168.56.103/index.php/ru/blog/posts/in-hac-habitasse-platea-dictumst | http://192.168.56.103/ru/blog/posts/in-hac-habitasse-platea-dictumst |
http://192.168.56.103/index.php?a=b | Не соответствует шаблону, поэтому редирект на /index.php/ru осуществляет Symfony, а Traefik, в свою очередь, перенаправляет на /ru . |
Схема (в случае чего – масштабирование я выключил):
Выход из пещеры
Заказываем самый дешевый VPS (ну или нормальный ), прописываем для него, допустим, A/AAAA записи в DNS (далее будут фигурировать хосты
demo.example.com
и traefik.example.com
), ставим обновляться систему (на VPS Arch Linux встречается довольно редко, поэтому далее я буду ориентироваться на Debian 12) и идем обедать. Потом наверное можно будет установить Docker. Проект удобно залить сочетанием
tar
и scp
. Для определенности пути пусть будут такими же – /srv/docker/traefik и /srv/docker/demo.
Демо-приложение
Добавим фрагмент в Dockerfile, чтобы оно работало под пользователем www-data (33:33):
FROM dunglas/frankenphp
# Install PHP extensions
RUN install-php-extensions \
intl
# Install composer and unzip
RUN set -eux; \
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer; \
# smoke test
composer --version; \
apt-get update; \
apt-get install -y --no-install-recommends \
unzip \
; \
rm -rf /var/lib/apt/lists/*
# Use the default production configuration
RUN cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
# Running as a Non-Root User
RUN set -eux; \
# Add additional capability to bind to port 80
setcap CAP_NET_BIND_SERVICE=+eip /usr/local/bin/frankenphp; \
# Give write access to /data/caddy and /config/caddy
chown -R www-data:www-data /data/caddy && chown -R www-data:www-data /config/caddy
USER www-data
На хосте (Debian) уже должен быть такой же пользователь, так что:
chown -R www-data:www-data /srv/docker/demo/app
В compose.yaml нам нужно поменять метку правила маршрутизации – теперь это будет конкретный хост. Также, забегая вперед, указываем точку входа websecure
:
services:
php:
build: .
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.http.routers.demo.rule=Host(`demo.example.com`)
- traefik.http.routers.demo.entrypoints=websecure
environment:
CADDY_GLOBAL_OPTIONS: "auto_https off"
SERVER_NAME: "http://"
volumes:
- ./app:/app
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
И уже пора настроить доверенный прокси, иначе, к примеру, не загрузится панель отладки из-за смешанного содержимого. В app/config/packages/framework.yaml добавляем такой фрагмент:
framework:
# ...
# you can use the 'PRIVATE_SUBNETS' string, which is replaced at
# runtime by the IpUtils::PRIVATE_SUBNETS constant
trusted_proxies: '127.0.0.1,PRIVATE_SUBNETS'
Как вариант, вместо этого можно объявить переменную окружения прямо в compose.yaml:
services:
php:
# ...
environment:
# ...
SYMFONY_TRUSTED_PROXIES: "private_ranges"
Конфигурация Traefik в файле
Далее предлагаю вместо параметров командной строки сделать эквивалентный конфигурационный файл /srv/docker/traefik/config/traefik.yaml:
providers:
docker:
exposedByDefault: false
api:
insecure: true
entryPoints:
web:
address: ":80"
Соответственно в compose.yaml убираем параметры командной строки и пробрасываем директорию:
services:
traefik:
image: traefik:v3.4
restart: unless-stopped
network_mode: host
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./config:/etc/traefik
Заодно перевел сокет докера в режим только для чтения.
Let's Encrypt
Сделаем автоматический https с редиректом. Для этого дополним раздел entryPoints
в настройках Traefik и добавим соответствующий обработчик сертификатов:
providers:
docker:
exposedByDefault: false
api:
insecure: true
entryPoints:
web:
address: ":80"
http:
redirections:
entrypoint:
to: websecure
scheme: https
websecure:
address: ":443"
http:
tls:
certResolver: letsencrypt
certificatesResolvers:
letsencrypt:
acme:
email: admin@example.com
storage: /etc/traefik/acme.json
tlsChallenge: {}
Точка web
на 80-м порту перенаправляет весь трафик в websecure
с изменением схемы URL на https. В свою очередь, websecure
использует Let's Encrypt (ACME) для выпуска сертификатов. По традиции для сертификации предлагается указать свой e-mail, а в качестве «испытания» (challenge) проще всего использовать TLS-ALPN-01: Traefik сам сначала выпустит самоподписанный сертификат, а потом уже получит доверенный. Поскольку мы пробросили директорию /etc/traefik, то данные о сертификатах будут сохранятся на постоянной основе.
Кстати если не хочется сразу же получать «боевые» сертификаты, можно использовать тестовый (staging) сервер:
certificatesResolvers:
letsencrypt:
acme:
email: admin@example.com
caServer: https://acme-staging-v02.api.letsencrypt.org/directory
storage: /etc/traefik/acme.json
tlsChallenge: {}
Разумеется, на такие сертификаты браузер и/или антивирус будут очень сильно ругаться.
Защита API и панели Traefik
Как и в виртуальной машине, сейчас панель доступна всем желающим на порту 8080. Защитим ее базовой HTTP-аутентификацией. Для хэширования пароля алгоритмом BCrypt по идее требуется утилита htpasswd. Альтернативы в виде Python или PHP такие себе – зачем они на хосте (хотя пых вроде как есть в контейнере)? MD5 и SHA-1 мы однозначно говорим «нет»! Так что придется установить…
apt install apache2-utils
В traefik.yaml «обнуляем» настройки API:
api: {}
Генерируем хэш пароля и сразу сохраняем результат в файл:
htpasswd -cbBC 10 /srv/docker/traefik/config/usersfile admin "P@ssw0rd"
Где:
-c
– создать новый файл;-b
– пароль будет указан в командной строке;-B
– хэширование алгоритмом BCrypt;-C
– время работы (computing time) BCrypt. Увеличил значение с 5 (по умолчанию) до 10.
Прописываем метки в compose.yaml (почему-то через traefik.yaml не заработало, скорее всего потому что указан только Докер как провайдер):
services:
traefik:
image: traefik:v3.4
restart: unless-stopped
network_mode: host
labels:
- "traefik.enable=true"
- "traefik.http.routers.dashboard.rule=Host(`traefik.example.com`) && (PathPrefix(`/api`) || PathPrefix(`/dashboard`))"
- "traefik.http.routers.dashboard.service=api@internal"
- "traefik.http.routers.dashboard.entrypoints=websecure"
- "traefik.http.routers.dashboard.middlewares=apiauth"
- "traefik.http.middlewares.apiauth.basicauth.usersfile=/etc/traefik/usersfile"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./config:/etc/traefik
В настройках базовой аутентификации как раз указываем сформированный файл соответствия пользователей и паролей. Также хочу обратить ваше внимание на принудительное указание внутреннего сервиса Traefik'а в настройках роутера.
Root или не root
Вот в чем вопрос, как говорится. Сейчас, очевидно, Traefik работает под root'ом, и это наверное не очень хорошо. К сожалению, все дело упирается в доступ к сокету Докера. Есть в немалой степени специально для этого написанный wollomatic/socket-proxy, таким образом сокет пробрасывается в контейнер прокси, а Трафик опосредованно обращается к Докеру по TCP.
Позволю себе выказать некоторый скепсис по поводу такой системы, хотя должен признать, что в изоляции сетей, а их предлагается создать две – «сервисную» Трафика и внутреннюю для прокси, определенный смысл есть. К тому же в самой документации предлагается именно изолировать сокет Докера через прокси, правда другой – tecnativa/docker-socket-proxy, заменой которому и является рассматриваемый.
На хосте создадим пользователя traefik
(в зависимости от того, предполагается ли интерактивная работа под ним и какая, скорректируйте команду создания):
useradd -c 'Traefik user' -M -s /usr/sbin/nologin traefik
Возможно это не очень красиво, но чтобы не создавать локальных групп и пользователей в контейнерах, мы можем ограничиться директивами user
в compose.yaml, который обретет примерно такой вид:
services:
dockerproxy:
image: wollomatic/socket-proxy:1
restart: unless-stopped
user: "65534:${DOCKERGID:-994}"
mem_limit: 64M
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
command:
- '-loglevel=error'
- '-listenip=0.0.0.0'
- '-allowfrom=traefik' # allow only hostname "traefik" to connect
- '-allowGET=/v1\..{1,2}/(version|containers/.*|events.*)'
- '-allowbindmountfrom=/var/log,/tmp' # restrict bind mounts to specific directories
- '-watchdoginterval=3600' # check once per hour for socket availability
- '-stoponwatchdog' # halt program on error and let compose restart it
- '-shutdowngracetime=5' # wait 5 seconds before shutting down
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- docker-proxynet # NEVER EVER expose this to the public internet!
# this is a private network only for traefik and socket-proxy
traefik:
image: traefik:v3.4
restart: unless-stopped
read_only: true
depends_on:
- dockerproxy
security_opt:
- no-new-privileges:true
ports:
- "80:10080" # use high ports inside the container so
- "443:10443" # we don't need to be root to bind the ports
networks:
- default
- docker-proxynet
labels:
- "traefik.enable=true"
- "traefik.http.routers.dashboard.rule=Host(`traefik.example.com`) && (PathPrefix(`/api`) || PathPrefix(`/dashboard`))"
- "traefik.http.routers.dashboard.service=api@internal"
- "traefik.http.routers.dashboard.entrypoints=websecure"
- "traefik.http.routers.dashboard.middlewares=apiauth"
- "traefik.http.middlewares.apiauth.basicauth.usersfile=/etc/traefik/usersfile"
volumes:
- ./config/traefik.yaml:/etc/traefik/traefik.yaml:ro # static configuration
- ./config/usersfile:/etc/traefik/usersfile:ro # dashboard basic auth
- ./config/acme.json:/etc/traefik/acme.json # certificate storage
user: "${TRAEFIKUID:-1000}"
networks:
default:
name: traefik
docker-proxynet:
internal: true
Добавился сервис dockerproxy
, различные защиты, две сети, как мы говорили выше, и т.д. Обратите внимание, что файлы конфигурации пробрасываем по одному в режиме «только чтение», за исключением хранилища сертификатов. И вновь перерабатываем traefik.yaml – явно прописываем доступ к Докеру по TCP и используемую сеть, а также повышаем номера портов:
providers:
docker:
exposedByDefault: false
endpoint: 'tcp://dockerproxy:2375'
network: traefik
api: {}
entryPoints:
web:
address: ":10080" # will be routed to port 80
http:
redirections:
entrypoint:
to: ":443"
scheme: https
websecure:
address: ":10443" # will be routed to port 443
http:
tls:
certResolver: letsencrypt
http3:
advertisedPort: 443
certificatesResolvers:
letsencrypt:
acme:
email: admin@example.com
storage: /etc/traefik/acme.json
tlsChallenge: {}
Если файла acme.json еще не существует, создадим его, чтобы при запуске приложения не возникла директория, и назначим права 600 (это требование Traefik):
touch /srv/docker/traefik/config/acme.json
chmod 600 /srv/docker/traefik/config/acme.json
Рекурсивно изменим владельца директории конфигурации:
chown -R traefik:traefik /srv/docker/traefik/config
Traefik можно было бы запустить, однако есть важный нюанс в виде реальных UID и GID. Особенно непредсказуемой в этом плане является группа docker
. Проще, конечно, если что подправить значения по умолчанию в compose.yaml или создать файл .env, но все же:
TRAEFIKUID="$(id -u traefik)" DOCKERGID="$(getent group docker | cut -d: -f3)" docker compose up -d
Теперь необходимо подключить демо-приложение к «сервисной» сети – /srv/docker/demo/compose.yaml:
services:
php:
build: .
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.http.routers.demo.rule=Host(`demo.example.com`)
- traefik.http.routers.demo.entrypoints=websecure
environment:
CADDY_GLOBAL_OPTIONS: "auto_https off"
SERVER_NAME: "http://"
SYMFONY_TRUSTED_PROXIES: "private_ranges"
volumes:
- ./app:/app
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
networks:
default:
name: traefik
external: true
Наконец, можно сделать docker compose up -d
и тут.
Заключение
Скорее всего у вас все это время вертелся в голове вопрос – зачем ставить Traefik перед Caddy (или FrankenPHP в данном случае), если у них очень много общего? Поддержка современных протоколов HTTP/2 и HTTP/3, автоматический HTTPS, даже балансировщик вообще говоря у Caddy тоже есть (хотя схема с метками, подобная Traefik, реализуется через некий плагин, на первый взгляд очень неочевидно в случае реплик сервисов). Причем Traefik даже хуже как бы, поскольку не умеет общаться с PHP.
Действительно, во многих случаях можно использовать Caddy с «ручным» обнаружением сервисов на основе хостов. Преимущества Traefik проявляются в случае достаточно сложных конфигураций с масштабированием и/или множества приложений (файлов compose.yaml) – достаточно добавить несколько меток, и ваш сервис опубликован.
Разумеется вместо Caddy в качестве веб-сервера для PHP можно использовать старый добрый Apache (особенно для Drupal фактически безальтернативно) или тоже старый, но злой Nginx, а для разработки вообще собственные сервера фреймворков Symfony (который как раз мог бы обслуживать демо-приложениие, но я решил представить вам FrankenPHP) или Laravel. Однако я хотел бы анонсировать вариант с Lighttpd – оказывается, есть и такая весьма интересная альтернатива всему вышеперечисленному. На мой взгляд, этот веб-сервер совершенно незаслуженно находится как бы в тени гигантов, а в качестве бэка для Трафика подходит идеально.
Категория: Программирование, веб | Опубликовано 01.08.2025 | Редакция от 03.08.2025
Похожие материалы
Перенос GitLab на другой сервер в Docker
Примерно год я мучался с GitLab на сервере с двумя гигабайтами оперативки. Когда оплаченный период закончился, решил взять более мощный VDS по формуле 4/4/30. До этого сам GitLab был установлен непосредственно из репозитория, но для экспериментов с Pages и т.д. нужен Docker. А раз он и так есть, почему бы не завернуть GitLab в контейнер? Заодно на сервер можно будет установить что-нибудь еще.