настройка self-hosted, часть 2: reverse proxy, мониторинг и защита сервисов

Содержание:

  1. Настройка DNS
  2. Настройка реверс-прокси Caddy
  3. Структура директорий для сервисов
  4. Настройка Prometheus
  5. docker-compose для мониторинга
  6. UFW-правило для node_exporter
  7. Запуск
  8. Настройка домена Grafana в Caddy
  9. Защита Grafana: права на Caddyfile, basic_auth, fail2ban
  10. Подключение Prometheus к Grafana
  11. Импорт дашбордов
  12. Версионирование конфигов в Git
  13. Памятка для добавления нового сервиса

Это вторая часть гайда по настройке self-hosted-инфраструктуры. В первой части мы поставили минимально необходимые утилиты, подняли Docker для наших сервисов, сделали базовую настройку безопасности (UFW, fail2ban, настроили ssh) и настроили минимальный мониторинг средствами DigitalOcean.

В этой части поднимем полноценный мониторинг на стеке Prometheus + Grafana, а также настроим домен для него.

Ожидается, что у читателя уже куплен домен. Подойдет абсолютно любой, у меня Hostinger.


Чеклист: что настроим в этой части

DNS и reverse proxy

Структура и конфиги

Сеть и UFW

Запуск

Защита Grafana (опционально)


Grafana я хочу открывать из браузера, поэтому ей нужен публичный вход по HTTPS - отсюда требования к настройке.

Главное


1. Настройка DNS

Реверс-прокси - это сервер, который стоит перед бэкенд-сервисами, принимает запросы от клиентов и проксирует на них. Клиент общается с реверс-прокси и не видит сам сервис. Назначений у реверс-прокси несколько, но в нашем случае важна именно маршрутизация.

Прежде чем запускать Caddy, домены должны резолвиться на IP нашего VPS. Иначе Let’s Encrypt не сможет проверить владение и не выдаст сертификаты.

В панели регистратора домена нужно добавить A-записи (где IP_VPS - IP вашего VPS):

A    @         IP_VPS     TTL 60-300
A    grafana   IP_VPS     TTL 300

@ - это обозначение корня домена, то есть это сам домен без точки слева. Т.е. example.com - это корень, а www.example.com, grafana.example.com и т.д. - это поддомены.

Каждый поддомен - это отдельная A-запись (в нашем случае grafana). DNS не наследует записи, т.е. если прописана @ запись для корня, это не значит, что grafana.example.com тоже будет резолвиться на указанный в @ IP. Конкретно для Grafana нам нужна только вторая запись, первую добавляем для блога.

Проверка через минуту-две (где example.com - ваш домен):

dig +short grafana.example.com

Он должен вернуть IP VPS.

2. Настройка реверс-прокси Caddy

Дока Caddy: https://caddyserver.com/docs/install#debian-ubuntu-raspbian

Установка:

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | \
  sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | \
  sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddy

После установки Caddy будет работать на 80 и 443 с дефолтным “Welcome”. UFW для них уже открыт (как открыть, если еще не - в предыдущей статье).

Заменим /etc/caddy/Caddyfile (где example.com - ваш домен):

{
    email username@example.com
}

example.com {
    respond "Hello from Caddy"
}

www.example.com {
    redir https://example.com{uri} permanent
}

На email в глобальном блоке Let’s Encrypt пришлет уведомление, если с сертификатом будут какие-то проблемы.

Валидируем и применяем конфиг:

sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
sudo journalctl -u caddy -n 30 --no-pager

В логах должно быть certificate obtained successfully и Valid configuration. Если открыть в браузере https://example.com, то увидим “Hello from Caddy”. Из терминала то же самое проверяется так:

curl -I https://example.com

Должен вернуться HTTP/2 200.

3. Структура директорий для сервисов

Все docker-сервисы будут храниться в ~/services/:

mkdir -p ~/services/observability/prometheus
cd ~/services/observability

4. Настройка Prometheus

Prometheus работает по pull-модели - он сам ходит по каждому таргету и забирает метрики по HTTP с пути /metrics. В конфиге ниже перечислен список адресов, к которым он стучится.

~/services/observability/prometheus/prometheus.yml:

global:
  scrape_interval: 15s
  evaluation_interval: 15s

scrape_configs:
  - job_name: 'prometheus'
    static_configs:
      - targets: ['localhost:9090']

  - job_name: 'node'
    static_configs:
      - targets: ['host.docker.internal:9100']

  - job_name: 'cadvisor'
    static_configs:
      - targets: ['cadvisor:8080']

У нас перечислено 3 таргета:

5. docker-compose для мониторинга

~/services/observability/docker-compose.yml:

services:
  prometheus:
    image: prom/prometheus:latest
    container_name: prometheus
    restart: unless-stopped
    ports:
      - "127.0.0.1:9090:9090"
    volumes:
      - ./prometheus:/etc/prometheus:ro
      - prometheus_data:/prometheus
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.path=/prometheus'
      - '--storage.tsdb.retention.time=30d'
      - '--storage.tsdb.retention.size=5GB'
      - '--web.enable-lifecycle'
    extra_hosts:
      - "host.docker.internal:host-gateway"
    mem_limit: 512m

  grafana:
    image: grafana/grafana:latest
    container_name: grafana
    restart: unless-stopped
    ports:
      - "127.0.0.1:3000:3000"
    volumes:
      - grafana_data:/var/lib/grafana
    environment:
      - GF_SECURITY_ADMIN_USER=admin
      - GF_SECURITY_ADMIN_PASSWORD=${GF_SECURITY_ADMIN_PASSWORD}
      - GF_USERS_ALLOW_SIGN_UP=false
      - GF_SERVER_ROOT_URL=https://grafana.example.com
    mem_limit: 512m

  node_exporter:
    image: prom/node-exporter:latest
    container_name: node_exporter
    restart: unless-stopped
    pid: host
    network_mode: host
    volumes:
      - /proc:/host/proc:ro
      - /sys:/host/sys:ro
      - /:/rootfs:ro
    command:
      - '--path.procfs=/host/proc'
      - '--path.sysfs=/host/sys'
      - '--path.rootfs=/rootfs'
      - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
    mem_limit: 64m

  cadvisor:
    image: gcr.io/cadvisor/cadvisor:latest
    container_name: cadvisor
    restart: unless-stopped
    privileged: true
    devices:
      - /dev/kmsg
    volumes:
      - /:/rootfs:ro
      - /var/run:/var/run:ro
      - /sys:/sys:ro
      - /var/lib/docker/:/var/lib/docker:ro
      - /dev/disk/:/dev/disk:ro
    ports:
      - "127.0.0.1:8080:8080"
    mem_limit: 256m

volumes:
  prometheus_data:
  grafana_data:

Пароль не держим в compose открытым текстом - ${GF_SECURITY_ADMIN_PASSWORD} подставляется из .env рядом с compose (создаем с правами 600, чтобы прочитать мог только владелец):

touch ~/services/observability/.env
chmod 600 ~/services/observability/.env
echo 'GF_SECURITY_ADMIN_PASSWORD=changeme' >> ~/services/observability/.env

Это первичный пароль admin, changeme заменим в разделе 10, когда зайдем в Grafana из веба. .env обязательно попадает в .gitignore (раздел 12), иначе пароль уедет в git.

grafana.example.com нужно поменять на свое значение.

Краткое пояснение:

6. UFW-правило для node_exporter

Несмотря на то что в конфиге host.docker.internal, под капотом это имя резолвится в IP моста docker0 (обычно 172.17.0.1) - то есть пакет все равно идет с docker-подсети на хост. UFW по дефолту блокирует такой трафик. Разрешаем трафик с 172.16.0.0/12 (все стандартные docker-подсети) на порт 9100 хоста:

sudo ufw allow from 172.16.0.0/12 to any port 9100 proto tcp
sudo ufw reload

Без этого правила Prometheus вернет context deadline exceeded, так как не сможет получить метрики.

7. Запуск

cd ~/services/observability
docker compose up -d
docker compose ps

Все четыре контейнера должны быть running. Проверим таргеты:

curl -s http://localhost:9090/api/v1/targets | jq '.data.activeTargets[] | {scrapeUrl, health}'

Вывод:

{
  "scrapeUrl": "http://cadvisor:8080/metrics",
  "health": "up"
}
{
  "scrapeUrl": "http://host.docker.internal:9100/metrics",
  "health": "up"
}
{
  "scrapeUrl": "http://localhost:9090/metrics",
  "health": "up"
}

Все три должны быть up. Если node - down, проверьте UFW из раздела 6 и что в docker-compose.yml из раздела 5 для node_exporter указан network_mode: host.

8. Настройка домена Grafana в Caddy

Чтобы дашборды с нашими метриками были доступны в браузере, Caddy нужно знать, куда проксировать запросы с адреса grafana.example.com. Настроим проксирование на наш localhost:3000, куда мапится порт 3000 контейнера с Grafana из раздела 5. В /etc/caddy/Caddyfile нужно добавить:

grafana.example.com {
    reverse_proxy localhost:3000
}

Дальше валидируем конфиг и делаем reload сервиса:

sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

Caddy сам получит TLS-сертификат (кому интересно, как он это делает: https://caddyserver.com/docs/automatic-https). Теперь Grafana доступна по https://grafana.example.com.

9. Защита Grafana: права на Caddyfile, basic_auth, fail2ban

Опциональный шаг.

В разделе 10 мы зайдем в Grafana под ее собственным admin-логином. Перед этим стоит добавить еще один слой защиты на уровне реверс-прокси: тогда брутфорсить логин Grafana через интернет не получится - запрос не дойдет до Grafana, пока не пройдена авторизация на Caddy.

Алгоритм:

  1. Закрываем права на Caddyfile, чтобы его могли читать только root и сам Caddy.
  2. Добавляем basic_auth в конфиг Caddy для целевого сервиса, который смотрит наружу - в нашем случае Grafana. Попытки логина будут видны в логах.
  3. Через fail2ban настраиваем бан IP при превышении порога неудачных попыток - как это делали в первом гайде для ssh.

9.1 Права на Caddyfile

Проверим, что Caddy запускается под своими юзером и группой:

systemctl cat caddy | grep -E '^(User|Group)='

Должно быть:

User=caddy
Group=caddy

Если у вас другие юзер и группа - не выполняем шаги из этого подраздела, иначе сервис упадет при перезапуске или при перепрочтении конфига.

Теперь, если файл доступен на чтение всем (644 root:root), как здесь:

ls -l /etc/caddy/Caddyfile
# -rw-r--r-- 1 root root

То меняем владельца группы на caddy и закрываем чтение для остальных:

sudo chown root:caddy /etc/caddy/Caddyfile
sudo chmod 640 /etc/caddy/Caddyfile

Будет так:

ls -l /etc/caddy/Caddyfile
# -rw-r----- 1 root caddy

Теперь читать файл могут только root и сервис caddy (по группе), писать может только root. Любой другой юзер на VPS получит Permission denied. Делаем релоад и смотрим статус (должно быть Active: active (running)):

sudo systemctl reload caddy
sudo systemctl status caddy

9.2 basic_auth для Grafana (и любого публичного сервиса с ограниченным доступом)

basic_auth - это доп. слой авторизации на уровне нашего реверс-прокси. До сервиса запрос не дойдет, пока не пройдена эта авторизация.

Нам нужно сгенерировать хеш, который затем положим в Caddyfile:

caddy hash-password

Задаем и подтверждаем пароль. Сохраняем его куда-нибудь себе. Команда вернет хеш - его нужно скопировать в /etc/caddy/Caddyfile в блок grafana.example.com -> basic_auth:

grafana.example.com {
    reverse_proxy localhost:3000
    basic_auth {
        <USER> <HASH>
    }
}

В конфиге выше <USER> - это ваш логин для basic_auth, <HASH> - хеш пароля.

Валидируем и перезагружаем:

sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

Открываем https://grafana.example.com (лучше в инкогнито). Браузер спросит логин и пароль до загрузки Grafana.

Раз в несколько месяцев или при подозрении на утечку стоит менять basic_auth пароль. Шаги те же:

  1. Генерим новый хеш: caddy hash-password
  2. Меняем хеш в /etc/caddy/Caddyfile
  3. Релоадим сервис: sudo systemctl reload caddy

9.3 fail2ban для защиты от перебора basic_auth

Если помните первую часть, то fail2ban - это сервис, который читает логи и банит IP по определенным параметрам. Сейчас мы настроим правило (jail) для basic_auth Caddy.

Для начала включим access-логи Caddy. Access-лог - это запись об HTTP-запросе (IP клиента, URL, статус ответа и т.д.). Caddy по умолчанию их не пишет, поэтому включаем вручную - читать эти логи будет fail2ban.

Добавляем в /etc/caddy/Caddyfile секцию log:

grafana.example.com {
    reverse_proxy localhost:3000
    basic_auth {
        admin $2a$14$qwerty...
    }
    log {
        output file /var/log/caddy/grafana-access.log {
            roll_size 10mb
            roll_keep 5
        }
        format json
    }
}

Валидируем конфиг:

sudo caddy validate --config /etc/caddy/Caddyfile

Создаем директорию для логов из конфига выше, выдаем права и релоадим, чекаем статус (ожидается Active: active (running)):

sudo mkdir -p /var/log/caddy
sudo chown -R caddy:caddy /var/log/caddy
sudo systemctl reload caddy
sudo systemctl status caddy

Зайдем на https://grafana.example.com, введем заведомо неправильный пароль. В логе будет запись с status: 401:

sudo tail /var/log/caddy/grafana-access.log

Создаем фильтр /etc/fail2ban/filter.d/caddy-grafana-auth.conf:

[Definition]
failregex = ^.*"remote_ip":"<HOST>".*"status":401.*$
datepattern = "ts":{EPOCH}
ignoreregex =

Проверим, что фильтр действительно ловит 401-е ошибки и распознает даты:

sudo fail2ban-regex -v /var/log/caddy/grafana-access.log /etc/fail2ban/filter.d/caddy-grafana-auth.conf | head -60

В выводе будет что-то вроде:

Running tests
=============

Use   failregex filter file : caddy-grafana-auth, basedir: /etc/fail2ban
Use      datepattern : "ts":{EPOCH} : "ts":{EPOCH}
Use         log file : /var/log/caddy/grafana-access.log
Use         encoding : UTF-8


Results
=======

Failregex: 34 total
|-  #) [# of hits] regular expression
|   1) [34] ^.*"remote_ip":"<HOST>".*"status":401.*$
|      <IP>  Sun May 10 01:39:10 2026
|      <IP>  Sun May 10 01:39:14 2026
`-

Ignoreregex: 0 total

Date template hits:
|- [# of hits] date format
|  [67] "ts":{EPOCH}
`-

Lines: 67 lines, 0 ignored, 34 matched, 33 missed

Как понять, что парсинг логов работает:

Создаем jail для Grafana-логов на 401: sudo nano /etc/fail2ban/jail.d/caddy-grafana-auth.conf

Содержимое:

[caddy-grafana-auth]
enabled = true
filter = caddy-grafana-auth
logpath = /var/log/caddy/grafana-access.log
maxretry = 5
findtime = 10m
bantime = 1h
banaction = ufw
ignoreip = 127.0.0.1/8 ::1

Сначала перезагружаем fail2ban, чтобы подхватился новый jail, и убеждаемся, что он активен:

sudo systemctl restart fail2ban
sudo fail2ban-client status caddy-grafana-auth

Теперь проверяем бан: открываем grafana.example.com (лучше в инкогнито) и вводим 5 раз неверный пароль. На шестую попытку браузер не откроет страницу вообще. Смотрим примененное правило:

sudo ufw status numbered

В UFW будет видно примененное правило by Fail2Ban after 5 attempts against caddy-grafana-auth:

Status for the jail: caddy-grafana-auth
|- Filter
|  |- Currently failed:	0
|  |- Total failed:	0
|  `- File list:	/var/log/caddy/grafana-access.log
`- Actions
   |- Currently banned:	1
   |- Total banned:	1
   `- Banned IP list:	<IP>

Status: active

     To                         Action      From
     --                         ------      ----
[ 1] Anywhere                   REJECT IN   <IP>               # by Fail2Ban after 5 attempts against caddy-grafana-auth
[ 2] 22/tcp                     ALLOW IN    Anywhere
[ 3] 80/tcp                     ALLOW IN    Anywhere
[ 4] 443/tcp                    ALLOW IN    Anywhere
[ 5] 9100/tcp                   ALLOW IN    172.16.0.0/12
[ 6] 22/tcp (v6)                ALLOW IN    Anywhere (v6)
[ 7] 80/tcp (v6)                ALLOW IN    Anywhere (v6)
[ 8] 443/tcp (v6)               ALLOW IN    Anywhere (v6)

Чтобы не ждать, можно разбанить себя:

sudo fail2ban-client unban <IP>

10. Подключение Prometheus к Grafana

Заходим в Grafana по https://grafana.example.com. Если делали раздел 9 - сначала браузер спросит basic_auth логин (от Caddy), затем откроется страница логина самой Grafana. Логинимся под admin и паролем changeme из .env (раздел 5), сразу меняем пароль. Новый пароль Grafana хранит в своем volume (grafana_data); GF_SECURITY_ADMIN_PASSWORD из .env применяется только при первой инициализации пустого volume, так что менять его в .env потом не нужно.

Добавляем data-source: Connections -> Data sources -> Add new data source -> Prometheus.

Connection -> Prometheus server URL: http://prometheus:9090 (Grafana и Prometheus в одной compose-сети, общаются по DNS-имени контейнера).

Остальное по дефолту. Save & Test -> “Successfully queried the Prometheus API”.

11. Импорт дашбордов

Dashboards -> New -> Import

Готовые дашборды: https://grafana.com/grafana/dashboards/ Достаточно просто вставить ID -> Load.

У меня такие:

Но, естественно, можно рисовать свои. После импорта ждем минут 15, чтобы накопились данные.

Часть панелей дашборда 1860 (systemd-юниты, процессы) останется пустой - под них node_exporter нужно запускать с флагами --collector.systemd и --collector.processes, я их не включал.

12. Версионирование конфигов в Git

(Настраивали Git в первой части)

Конфиги кладем в git, состояние уже хранится в docker volumes (в первой части мы настроили бэкапы DO, их хватит):

cd ~/services/observability
git init
echo "*.log" > .gitignore
echo ".env" >> .gitignore   # секреты в env не коммитим
git add .
git status
git commit -m "Initial observability setup"

Можно запушить в приватную репу. Тогда при пересоздании VPS достаточно git clone && docker compose up -d.

13. Памятка для добавления нового сервиса

13.1 Базовые шаги

Детали см. в разделах 4-5.

  1. Директория ~/services/<имя> со своим docker-compose.yml.
  2. Сервис слушает только 127.0.0.1:port. Никогда не 0.0.0.0.
  3. В docker-compose.yml обязательно:
  1. Конфиги трекаются Git.

13.2 Если сервис должен быть доступен наружу

Детали см. в разделе 8.

  1. У DNS-провайдера задана A-запись <имя>.example.com -> IP_VPS.
  2. Проверяем, что запись резолвится: dig +short <имя>.example.com возвращает IP_VPS. Это обязательно сделать до запуска Caddy - иначе Let’s Encrypt не сможет пройти ACME challenge и не выдаст сертификат.
  3. Caddy: в /etc/caddy/Caddyfile настроено проксирование reverse_proxy localhost:<port>.
  4. Проверяем валидность конфига и перечитываем: sudo caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy.

13.2.1 Если у сервиса есть UI-админка или дашборд

Детали см. в разделе 9.

Закрываем за basic_auth Caddy:

  1. Если еще не сделано на первом сервисе - закрыть права на Caddyfile: sudo chown root:caddy /etc/caddy/Caddyfile && sudo chmod 640 /etc/caddy/Caddyfile (раздел 9.1). Это разовая настройка для всего файла, не на каждый сервис.
  2. Сгенерировать хеш пароля: caddy hash-password.
  3. Добавить в Caddyfile в блок сервиса: basic_auth { admin <hash> }.
  4. Включить access-лог в тот же блок: log { output file /var/log/caddy/<имя>-access.log { roll_size 10mb roll_keep 5 } format json }. Если директория /var/log/caddy еще не создана - sudo mkdir -p /var/log/caddy && sudo chown -R caddy:caddy /var/log/caddy.
  5. Создать фильтр и jail в fail2ban по образцу caddy-grafana-auth из раздела 9.3, поменяв путь к логу.

13.3 Если сервис не должен смотреть наружу постоянно, но при необходимости нужно подключиться

Доступ через SSH-туннель: ssh -L <local_port>:localhost:<port> dovps (где dovps - SSH-alias из ~/.ssh/config, либо <user>@<IP_VPS>).

После запуска команды SSH-сессия остается открытой - туннель живет, пока мы ее не закроем. В браузере открываем http://localhost:<local_port>.

Пример: подключиться напрямую к Prometheus, чтобы посмотреть Status -> Targets, проверить up{} через PromQL или дернуть сырой /metrics:

ssh -L 9090:localhost:9090 dovps
# в браузере: http://localhost:9090

Это работает, потому что у Prometheus в compose ports: "127.0.0.1:9090:9090" - порт смаплен на loopback хоста. Снаружи он закрыт (UFW не пускает), но SSH, зайдя на VPS, может постучать туда от имени самой машины.

13.4 Если хотите собирать метрики с сервиса через Prometheus

Детали см. в разделах 4-5.

  1. Добавить в ~/services/observability/prometheus/prometheus.yml:
- job_name: '<имя>'
  static_configs:
    - targets: ['<адрес>:<port>']

Адрес зависит от того, в какой docker-сети будет жить сервис:

  1. Перечитаем конфиг Prometheus без рестарта контейнера:
docker compose exec prometheus kill -HUP 1

Команда посылает SIGHUP процессу с PID 1 внутри контейнера Prometheus - это его собственный сигнал на reload конфига.

13.5 Чеклист для нового сервиса