Заметки о домашнем сервере

sparn про Linux, Docker и всякий self-hosting. Пишу, чтобы не забыть.

До этого я держал пароли в большом облачном менеджере по подписке. Работало нормально, но платить за хранилище текстовых строк как-то жаба душила, да и хотелось, чтобы база лежала у меня, а не у дяди. Поднял Vaultwarden.

Что это

Vaultwarden — это лёгкая реализация сервера Bitwarden на Rust. Совместима с официальными клиентами Bitwarden: браузерные расширения, десктоп, мобильные приложения — всё цепляется к твоему серверу, просто указываешь свой адрес вместо облачного. При этом она в разы прожорливее официального сервера, контейнер ест десятки мегабайт памяти и крутится хоть на тостере.

Установка

Стек в /app/vaultwarden/:

services:
  vaultwarden:
    image: vaultwarden/server:latest
    container_name: vaultwarden
    restart: unless-stopped
    environment:
      DOMAIN: "https://vault.example.home"
      SIGNUPS_ALLOWED: "false"
      ADMIN_TOKEN: "${ADMIN_TOKEN}"
    volumes:
      - ./data:/data
    ports:
      - 8080:80

Пара важных моментов. SIGNUPS_ALLOWED: false ставлю сразу после того, как зарегил себе и домашним аккаунты — иначе любой, кто доберётся до адреса, заведёт себе учётку. Доступ снаружи обязательно через HTTPS, реверс-прокси с валидным сертификатом: расширения Bitwarden просто откажутся работать по голому HTTP.

Admin token

ADMIN_TOKEN — это ключ от админ-панели на /admin, где видно пользователей и настройки. Генерю его хешем, а не открытой строкой:

docker run --rm vaultwarden/server /vaultwarden hash

Команда спросит пароль и выдаст argon2-хеш — его и кладу в .env. Если оставить токен пустым, админка будет открыта вообще без пароля, что довольно весело и грустно одновременно.

Клиенты

Тут вообще без сюрпризов. В браузерном расширении Bitwarden перед логином жму на шестерёнку, в поле Server URL вписываю свой адрес — и дальше всё как с обычным Bitwarden. Автозаполнение, генератор паролей, синхронизация между устройствами. Мобильное приложение так же. Домашние даже не заметили переезда.

Самое важное — бэкап

Вся база — это SQLite-файл и пара ключей в папке data. Потеряешь её — потеряешь вообще все пароли разом, и это будет не «неприятно», а «катастрофа». Поэтому бэкап настроил первым делом, ещё до того, как занёс реальные пароли.

Простой ночной дамп с ротацией:

#!/bin/bash
DST=/mnt/backup/vaultwarden/$(date +%F)
mkdir -p "$DST"
sqlite3 /app/vaultwarden/data/db.sqlite3 ".backup '$DST/db.sqlite3'"
cp /app/vaultwarden/data/rsa_key.* "$DST/"
find /mnt/backup/vaultwarden -maxdepth 1 -type d -mtime +30 -exec rm -rf {} +

Плюс эта папка уезжает в офлайн-копию раз в неделю. Менеджер паролей — это ровно тот сервис, где экономить на бэкапах нельзя ни в одном месте.

Долго ставил uBlock Origin в каждый браузер и каждый раз заново на новом устройстве. Потом понял, что глупо чинить проблему на десяти клиентах, когда можно один раз на уровне сети. Поднял AdGuard Home — это локальный DNS-сервер, который режет рекламу и трекеры до того, как они вообще загрузятся. На телефоне, на телевизоре, в приложениях — везде, без расширений.

Как это работает

Идея простая. Когда устройство хочет загрузить условный ads.example.com, оно сперва спрашивает у DNS его IP. AdGuard Home смотрит в блок-листы, видит там этот домен и отвечает «нет такого». Реклама просто не подгружается, страница чище и грузится быстрее. Бонусом — приватность: трекеры аналитики тоже сидят в этих списках.

Установка

Стек в /app/adguard/, обычный compose:

services:
  adguardhome:
    image: adguard/adguardhome:latest
    container_name: adguardhome
    restart: unless-stopped
    network_mode: host
    volumes:
      - ./work:/opt/adguardhome/work
      - ./conf:/opt/adguardhome/conf

network_mode: host тут важен — DNS работает на 53 порту, и пробрасывать его через bridge-сеть Docker неудобно. После первого старта вебморда висит на :3000, проходишь визард, и дальше панель и сам DNS уже на :80.

Блок-листы

Из коробки включён базовый AdGuard-фильтр. Я докинул ещё пару проверенных:

  • AdGuard DNS filter (основной)
  • OISD Big — большой общий список рекламы и трекеров
  • AdAway — заточен под мобильную рекламу

Списки обновляются автоматически. Главное — не уходить в дебри и не подключать десяток агрессивных листов: начнут отваливаться нормальные сайты, и потом сиди разбирайся, почему не грузится корзина в магазине.

Подключаю всю сеть

Самый сок — прописать AdGuard как DNS на роутере. Тогда все устройства в доме автоматически ходят через него, ничего настраивать на клиентах не надо. В админке роутера в настройках DHCP меняю DNS на IP сервера с AdGuard:

DNS server: 192.168.1.10

После этого новые устройства, цепляясь к Wi-Fi, получают этот DNS сами.

Статистика

Отдельное удовольствие — дашборд. Видно, сколько запросов прошло, сколько заблокировано, какие домены чаще всего стучатся. У меня стабильно режется около 15–20% всех DNS-запросов. Особенно показательно смотреть, как смарт-телевизор в простое ломится на десятки трекинговых доменов в час — после AdGuard эта телеметрия просто упирается в стену.

Если что-то нужное случайно заблокировалось — в логе запросов видно домен, добавляешь его в исключения в один клик. За пару месяцев белый список у меня вышел из трёх записей. Мелочь, а сеть стала заметно чище.

Сегодня мониторинг прислал, что на сервере осталось 3% свободного места. Паника, бэкапы, всё дело. Оказалось — виноват Docker, а конкретно накопившийся за полгода мусор: висячие образы, старые слои и build cache, который я пересобирал десятки раз.

Первое, что надо посмотреть — кто сколько ест:

docker system df

У меня вышло примерно так:

TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          61        12        18.4GB    14.1GB (76%)
Containers      14        11        220MB     180MB
Local Volumes   9         8         4.2GB     0B
Build Cache     312       0         9.8GB     9.8GB

Build cache на 9.8 ГБ — это он, родимый. Чистим аккуратно.

Снести вообще всё неиспользуемое (образы, сети, остановленные контейнеры):

docker system prune -a

Отдельно вынести build cache, если предыдущей команды показалось мало:

docker builder prune

Важно: -a сносит все образы, у которых нет запущенного контейнера. Если какой-то образ нужен, но контейнер из него сейчас не крутится — он тоже улетит и будет качаться заново. Тома (volumes) prune по умолчанию не трогает, и слава богу, иначе бы я сейчас восстанавливал базы.

Итог: освободил 23 ГБ за минуту. Записал себе в cron еженедельный docker system prune -f без -a, чтобы хотя бы висячие слои не копились до следующей паники.

Несколько лет я хранил фотки в Nextcloud — он у меня и так крутится на домашнем сервере, есть приложение Photos, синхронизация с телефона работает. Казалось бы, зачем что-то менять. А менять пришлось, потому что на коллекции в ~80 тысяч снимков всё это начало откровенно тормозить.

Что бесило в Nextcloud

Главная боль — листание галереи. Превьюшки генерируются лениво, и при заходе в папку с тысячами фоток интерфейс просто задумывался на несколько секунд. Поиск был никакой: только по имени файла и по дате. Найти «фотку с собакой на даче летом» — это руками листать.

Распознавания лиц и объектов из коробки нет, надо ставить отдельное приложение Recognize, которое прожорливое и всё равно работало через раз. В общем, Nextcloud — отличный комбайн для файлов, но как фотохранилище он вторичен по дизайну.

Почему Immich

Immich заточен именно под фотки, и это чувствуется сразу. Что зацепило:

  • Скорость. Галерея на десятки тысяч снимков скроллится плавно, превью отдаются мгновенно.
  • ML-поиск. Распознаёт лица (можно назвать человека — и вот все его фото), ищет по объектам. Я серьёзно набрал в поиске «cat» и получил всех котов из архива. Работает на CLIP-модели локально, ничего наружу не уходит.
  • Мобильное приложение. Реально хорошее. Автобэкап с телефона включил один раз и забыл — новые фотки сами уезжают на сервер.
  • Карта и таймлайн по EXIF — приятный бонус.

Как поднял

Стандартный compose-стек в /app/immich/, по официальному примеру. Ключевое — отдельный том под загрузки и Postgres с расширением для векторного поиска:

services:
  immich-server:
    image: ghcr.io/immich-app/immich-server:release
    volumes:
      - ${UPLOAD_LOCATION}:/usr/src/app/upload
      - /etc/localtime:/etc/localtime:ro
    env_file: .env
    ports:
      - 2283:2283
    depends_on: [redis, database]
    restart: always

  immich-machine-learning:
    image: ghcr.io/immich-app/immich-machine-learning:release
    volumes:
      - model-cache:/cache
    restart: always

ML-контейнер первый раз тянет модели — пара минут, и потом индексация всего архива идёт в фоне. На моём железе без GPU 80 тысяч фоток прожевались за ночь.

Импорт старого архива сделал через immich-cli:

immich upload --recursive /mnt/photos/archive

Осадочек

Immich официально предупреждает, что он ещё развивается и формат может меняться между релизами — поэтому я не выкинул старый архив и держу его отдельной холодной копией. Бэкаплю и базу Postgres, и папку upload. Но за полгода ни одного сюрприза не словил, обновления накатываются чисто.

Nextcloud у меня остался — но теперь чисто под документы и файлопомойку, чем он, собственно, и хорош.

Ровно год назад я собрал свой первый настоящий домашний сервер. До этого был старый ноут с пыхтящим вентилятором, на котором кое-как крутилась пара контейнеров, но это не считается. А тут — нормальный мини-сервер на i5-12400, который сейчас держит медиатеку, заметки, умный дом, файлохранилище и мониторинг. Пора подвести итоги: что зашло, а на каких граблях я станцевал и чего бы себе посоветовал год назад.

Что бы сделал иначе

Взял бы сразу больше RAM. Стартовал с 16 ГБ, думал «куда мне больше». К осени упёрся: Jellyfin при транскоде, Home Assistant, пара баз данных, мониторинг — и swap начал поскрипывать. Докупил до 64 ГБ, и сразу стало вольготно. RAM — это то, на чём в домашнем сервере экономить не стоит, она дешёвая, а заканчивается всегда внезапно.

Сразу взял бы нормальный SSD под систему и контейнеры. Я зачем-то поставил систему на старый SATA-SSD «который валялся», а под данные — большой HDD. В итоге часть баз и кэшей жила на медленном диске, и я месяц гадал, почему интерфейсы подтормаживают. Поставил NVMe — всё залетало. Под систему и горячие данные — только NVMe, HDD оставить для медиа и архивов.

Подписал бы кабели. Смешно, но это реально один из главных уроков. За год накопилось столько проводов — питание, два сетевых, USB-донгл, диски — что когда что-то надо переткнуть, я каждый раз играю в сапёра. Купил бы за копейки набор маркеров для кабелей в самом начале. Сейчас всё подписано, но прозрение пришло через боль.

Никогда не держал бы прод-данные без бэкапа. Тут была почти трагедия. Семейные заметки и фотоархив какое-то время жили в единственном экземпляре на одном диске. Диск начал сыпать ошибками в SMART, я успел в последний момент. После этого настроил нормальные бэкапы: ежедневный снапшот важных данных на второй диск и еженедельная выгрузка в облако шифрованным архивом. Правило простое: если данные существуют в одном месте — их не существует.

# то, что надо было сделать в первый же день, а не в панике потом
restic -r /mnt/backup/restic backup /app /mnt/media/photos
restic -r /mnt/backup/restic forget --keep-daily 7 --keep-weekly 4 --prune

Чему научился

Что Docker Compose — это лучшее, что случилось с самохостингом. Весь мой сервер описан текстовыми файлами в /app/<сервис>/, и если железо завтра помрёт, я подниму всё на новом за вечер. Раньше я настраивал сервисы руками и не помнил через месяц, что и где менял.

Что мониторинг и алерты надо ставить не «когда-нибудь потом», а сразу. Пока сервис не следит сам за собой, ты узнаёшь о проблемах от домашних, а это худший вид мониторинга.

Что не надо гнаться за «правильным» и сложным решением, если простое закрывает задачу. Я пару раз закапывался в навороченные стеки, хотя хватило бы одного контейнера.

Что реально пригодилось

Из всего зоопарка ежедневно работают три вещи: медиатека (её любит вся семья), умный дом с датчиками протечки (уже дважды спас от потопа) и заметки. Остальное — приятный бонус и площадка для экспериментов.

Год назад это был способ «поиграться с технологиями». Сейчас это незаметная инфраструктура дома, которой пользуются все, не зная, что там внутри. По-моему, это и есть признак, что всё получилось. Спасибо, что читаете, — впереди ещё много экспериментов.

Какое-то время мой подход к мониторингу сервера был простой: что-то отвалилось — узнаю, когда сам захочу зайти и обнаружу, что не работает. Обычно в самый неудобный момент. Надоело. Хотелось, чтобы сервер сам стучался мне в телефон, когда какой-нибудь сервис лёг или диск начал забиваться. Без энтерпрайз-эзотерики на пол-стойки — у меня дома один сервер, а не дата-центр.

В итоге собрал связку из двух частей: Uptime Kuma следит, что сервисы вообще живые, а Grafana с node_exporter показывает, как себя чувствует железо. Оба алертят в Telegram.

Uptime Kuma

Это самая приятная штука для домашнего мониторинга, что я видел. Красивый дашборд со статусом всех сервисов, история аптайма, и настраивается мышкой за пять минут. Поднимаю в /app/uptime-kuma/:

services:
  uptime-kuma:
    image: louislam/uptime-kuma:1
    container_name: uptime-kuma
    volumes:
      - /app/uptime-kuma/data:/app/data
    ports:
      - "3001:3001"
    restart: unless-stopped

Дальше через веб-морду добавляю мониторы: HTTP-проверку на каждую веб-морду (медиатека, заметки, реверс-прокси), TCP-пинг на MQTT-брокер, обычный ping на роутер и на пару внешних адресов, чтобы понимать, дома проблема или у провайдера. Каждому монитору цепляю Telegram-нотификацию.

Бот делается тривиально: пишем @BotFather, командой /newbot получаем токен, потом узнаём свой chat id (я просто написал боту и дёрнул https://api.telegram.org/bot<ТОКЕН>/getUpdates). Эти два значения вставляем в настройки нотификации Uptime Kuma — и всё. Когда сервис не отвечает 60 секунд, в телефон падает сообщение, когда поднялся — приходит «recovered».

Grafana и железо

Uptime Kuma знает только «жив/не жив». А мне ещё интересно, не перегревается ли процессор летом и не кончается ли место на диске. Для этого — node_exporter, который снимает метрики с хоста, Prometheus, который их складывает, и Grafana, которая рисует графики.

  node_exporter:
    image: prom/node-exporter:latest
    container_name: node_exporter
    command:
      - '--path.rootfs=/host'
    pid: host
    volumes:
      - '/:/host:ro,rslave'
    ports:
      - "9100:9100"
    restart: unless-stopped

В Grafana импортирую готовый дашборд Node Exporter Full (id 1860) — и сразу получаю красивые графики CPU, RAM, дисков, сети и температуры. Особенно люблю панель с температурой: летом сразу видно, когда пора чистить радиатор от пыли.

На критичные метрики навесил алерты прямо в Grafana — например, диск заполнен больше 85% или температура CPU выше 80 градусов держится пять минут. Алерты тоже уходят в тот же Telegram-бот, через тот же contact point. Один бот на всё, чтобы не плодить чаты.

Пара выводов

  • Не надо тащить полноценный Prometheus+Grafana, если хочется просто «упал/поднялся» — Uptime Kuma в одиночку закрывает 80% потребностей и ставится за минуты.
  • Алерты обязательно надо настраивать «с задержкой» (for: 5m и аналоги в Kuma), иначе при моргнувшем интернете телефон превращается в пулемёт уведомлений в три часа ночи. Проверено.
  • Самый ценный монитор — это ping на внешний адрес. Половина «падений» сервисов на деле оказывалась морганием провайдера, и теперь я это сразу вижу.

Теперь сервер сам докладывает о проблемах, и я узнаю о них до того, как о них узнают домашние. Спокойствие за пару вечеров настройки.

Всё началось с того, что у меня зимой в кладовке с трубами однажды чуть не прихватило воду — отопление барахлило, а я узнал об этом, только когда полез за консервами. Подумал: дом должен сам мне сообщать о таких вещах, а не я бегать с термометром. Так на сервере поселился Home Assistant.

Home Assistant — это опенсорсный центр умного дома. Никаких облаков производителя, всё крутится локально, данные не утекают неизвестно куда, и работает даже если интернет отвалился.

Запуск в Docker и Zigbee-донгл

Я не стал плодить кучу облачных гаджетов от разных вендоров, а пошёл по пути Zigbee — это единый радиопротокол, к которому цепляется куча дешёвых датчиков от любых производителей. В сервер воткнут USB-донгл Sonoff ZBDongle-E (на чипе EFR32MG21), а разговаривает с ним Zigbee2MQTT.

Связка такая: датчики → Zigbee2MQTT → MQTT-брокер (Mosquitto) → Home Assistant. Поднимаю всё одним compose в /app/homeassistant/:

services:
  homeassistant:
    image: ghcr.io/home-assistant/home-assistant:stable
    container_name: homeassistant
    network_mode: host
    volumes:
      - /app/homeassistant/config:/config
      - /etc/localtime:/etc/localtime:ro
    restart: unless-stopped

  zigbee2mqtt:
    image: koenkk/zigbee2mqtt:latest
    container_name: zigbee2mqtt
    volumes:
      - /app/zigbee2mqtt/data:/app/data
    devices:
      - /dev/serial/by-id/usb-ITead_Sonoff_Zigbee_3.0_USB_Dongle_Plus_xxxx-if00:/dev/ttyACM0
    restart: unless-stopped

Важный момент: устройство пробрасываю не как /dev/ttyACM0, а через стабильный путь /dev/serial/by-id/.... Иначе после перезагрузки донгл может переименоваться в ttyACM1, и Zigbee2MQTT его не найдёт. Точный путь смотрим командой ls -l /dev/serial/by-id/.

Датчики

По дому развесил недорогие Aqara: температура/влажность в комнатах, датчик протечки под мойкой и за стиралкой, пара датчиков открытия на дверях. Цепляются они через MQTT-интеграцию, после сопряжения сами появляются в Home Assistant как сущности вида sensor.temperatura_kladovka или binary_sensor.protechka_mojka. Батарейки живут больше года, так что обслуживание нулевое.

Автоматизации

Вот ради чего всё затевалось. Две простые автоматизации закрыли мою главную боль. Первая — если в кладовке холодает ниже 8 градусов, прилетает уведомление на телефон:

- alias: "Кладовка остывает"
  trigger:
    - platform: numeric_state
      entity_id: sensor.temperatura_kladovka
      below: 8
      for:
        minutes: 10
  action:
    - service: notify.mobile_app_telefon
      data:
        title: "Холодает в кладовке"
        message: "Температура {{ states('sensor.temperatura_kladovka') }}°C — проверь отопление"

for: minutes: 10 тут не просто так — без него датчик может дёрнуться от сквозняка и завалить телефон ложными тревогами. А вот протечка — наоборот, реагируем мгновенно:

- alias: "Протечка под мойкой"
  trigger:
    - platform: state
      entity_id: binary_sensor.protechka_mojka
      to: "on"
  action:
    - service: notify.mobile_app_telefon
      data:
        title: "⚠️ ПРОТЕЧКА"
        message: "Сработал датчик под мойкой!"
        data:
          priority: high

Итог

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

У меня на сервере крутится с десяток сервисов: медиатека, заметки, файлопомойка, пара админок. Все они слушают свои порты, а наружу торчит один реверс-прокси, который разруливает, кого куда пускать по доменному имени. Долгое время этим занимался nginx. Работал, не жаловался — но обслуживать его конфиги и сертификаты надоело настолько, что я в один выходной взял и переехал на Caddy.

Что бесило в nginx

Сам по себе nginx прекрасен, вопросов нет. Но вокруг него я насобирал зоопарк:

  • отдельный certbot в cron, который выпускает и продлевает сертификаты Let's Encrypt;
  • руками прописанные пути к fullchain.pem и privkey.pem в каждом server-блоке;
  • бойлерплейт на редирект с 80 на 443, на ssl_protocols, на заголовки;
  • и классика — забыл перезагрузить nginx после продления серта, словил протухший сертификат, узнал об этом от жены, которая не смогла зайти на семейные заметки.

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

Caddyfile, который заменил всё

Способ есть, и он называется Caddy. Весь мой конфиг для двух сервисов выглядит так:

films.example-home.lan {
    reverse_proxy localhost:8096
}

notes.example-home.lan {
    reverse_proxy localhost:8080
}

Всё. Это не урезанный пример — это реально рабочий минимум. Caddy сам:

  • сходит в Let's Encrypt, выпустит сертификат на каждый домен;
  • будет молча его продлевать в фоне, без cron и без моего участия;
  • поднимет редирект с HTTP на HTTPS;
  • выставит вменяемые заголовки безопасности по умолчанию.

Запускаю всё тем же compose, конфиг лежит в /app/caddy/:

services:
  caddy:
    image: caddy:2
    container_name: caddy
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"   # HTTP/3 (QUIC)
    volumes:
      - /app/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
      - /app/caddy/data:/data
      - /app/caddy/config:/config
    restart: unless-stopped

Обратите внимание на 443:443/udp — это для HTTP/3. Caddy умеет его из коробки, достаточно пробросить UDP-порт, и современные браузеры сами договорятся о QUIC. На спидтестах внутри локалки разницы я особо не заметил, но при доступе снаружи через мобильный интернет страницы админок стали ощутимо живее открываться.

Перезагрузка без даунтайма

Добавил новый сервис — дописал три строчки в Caddyfile и сделал:

docker exec caddy caddy reload --config /etc/caddy/Caddyfile

reload подхватывает изменения на лету, без обрыва текущих соединений. Никакого nginx -t && systemctl reload с замиранием сердца.

Что в итоге

/data я смонтировал в volume не просто так — там лежат выпущенные сертификаты и ACME-аккаунт. Если его потерять, Caddy перевыпустит сертификаты заново, но можно упереться в рейт-лимиты Let's Encrypt. Так что эта папка — то, что обязательно попадает в бэкап.

Конфиг ужался раз в десять, certbot из крона выпилил, про протухшие сертификаты забыл как страшный сон. Единственный минус — экосистема плагинов у nginx всё-таки богаче, и для совсем хитрых сценариев иногда приходится лезть в документацию. Но для домашнего реверс-прокси на десяток сервисов Caddy — это просто счастье. Жалею, что тянул с переездом так долго.

Долгое время вся коллекция фильмов и музыки у меня жила просто папками на сетевом диске. Открываешь SMB-шару с телевизора, листаешь имена файлов вида Movie.2014.1080p.BluRay.x264.mkv и пытаешься вспомнить, что это вообще такое. Постеров нет, прогресс просмотра не сохраняется, на телефоне вообще боль. Поставил Jellyfin — и наконец перестал стыдиться собственной медиатеки.

Jellyfin — это бесплатный медиасервер, форк старого Emby, полностью опенсорсный, без подписок и облачных аккаунтов. Он сам подтягивает метаданные, постеры, описания, раскладывает всё по библиотекам и отдаёт клиентам на любой платформе.

Compose и проброс iGPU

Главная фишка моего сервера (Intel i5-12400, встроенное видео UHD 730) — аппаратное транскодирование через QuickSync. Без него процессор при перекодировании 4K в реальном времени уходит в потолок и кулер начинает гудеть как пылесос. С QuickSync та же задача — это пара процентов CPU и тёплый радиатор.

Чтобы Jellyfin в контейнере увидел iGPU, нужно пробросить /dev/dri:

services:
  jellyfin:
    image: jellyfin/jellyfin:latest
    container_name: jellyfin
    user: 1000:1000
    network_mode: host
    devices:
      - /dev/dri:/dev/dri
    group_add:
      - "989"          # gid группы render на хосте, см. getent group render
    volumes:
      - /app/jellyfin/config:/config
      - /app/jellyfin/cache:/cache
      - /mnt/media/films:/media/films:ro
      - /mnt/media/series:/media/series:ro
      - /mnt/media/music:/media/music:ro
    restart: unless-stopped

Пара граблей, на которые я наступил, чтоб потом не гуглить то же самое:

  • group_add с правильным gid группы render обязателен, иначе контейнер видит /dev/dri, но прав на него нет. Узнать gid: getent group render.
  • Библиотеки монтирую :ro — Jellyfin незачем писать в коллекцию, пусть только читает.
  • network_mode: host упрощает работу DLNA и обнаружение клиентов в локалке.

После старта в админке идём в Dashboard → Playback и включаем Intel QuickSync (QSV), выбираем нужные кодеки (H.264, HEVC, VP9). Проверить, что транскод реально идёт на железе, можно так:

docker exec jellyfin /usr/lib/jellyfin-ffmpeg/vainfo

Если выдаёт список профилей VAProfileH264 и прочие — всё ок, iGPU подхватился.

Организация библиотек

Тут весь секрет — правильные имена файлов и папок, тогда скрейпер не промахивается. Для фильмов:

films/
  Дюна (2021)/Dune.2021.2160p.mkv
  Бегущий по лезвию (1982)/Blade.Runner.1982.mkv

Для сериалов — папка сериала, внутри Season 01, файлы вида S01E03. Год в скобках критичен: без него Jellyfin путает ремейки и оригиналы. Музыку раскладываю Исполнитель/Альбом/01 - Трек.flac, теги читает из самих файлов.

Клиенты

На телевизоре (LG с webOS) поставил официальное приложение из стора — работает на удивление бодро. На телефоне — Findroid под Android, приятнее официального. На втором ТВ воткнул Android-приставку и гоняю Jellyfin Media Player. Прогресс синхронизируется между всеми устройствами: начал смотреть на телефоне в метро, дома продолжил с того же места на большом экране.

Отдельный кайф — что вся семья завела свои профили, у каждого свой список «продолжить просмотр» и родительский контроль для детского профиля настраивается в два клика.

Итог: коллекция перестала быть свалкой файлов и стала нормальным сервисом, которым реально пользуются домашние. Жалею только, что не сделал этого раньше.

Обещал в прошлый раз — рассказываю, как я бэкаплю домашний сервер. Тема нудная ровно до того момента, как у тебя умирает диск с фотками за десять лет. После этого она становится самой интересной темой в твоей жизни, но уже поздно. Так что давайте до.

Правило 3-2-1

Если коротко, это здравый смысл, оформленный в цифры:

  • 3 копии данных;
  • на 2 разных носителях;
  • 1 копия — вне дома (offsite).

У меня это раскладывается так. Оригинал живёт на сервере (раз). Бэкап едет на внешний HDD, который воткнут в сервер по USB (два, другой носитель). И копия этого репозитория уезжает offsite — на удалённое хранилище через тот же restic (три, вне дома). Если дома случится потоп, пожар или просто кто-то унесёт коробку — данные переживут.

Почему restic

Перепробовал я разное, остановился на restic. Один бинарь, дедупликация (одинаковые куски хранятся один раз — экономит прилично), всё шифруется на клиенте перед записью. Последнее особенно важно для offsite-копии: на удалённом хранилище лежит зашифрованный мусор, и мне всё равно, кто его теоретически увидит.

Инициализируем репозиторий на внешнем диске один раз:

export RESTIC_REPOSITORY=/mnt/backup-hdd/restic
export RESTIC_PASSWORD_FILE=/root/.restic-pass
restic init

Пароль я держу в файле и отдельно записал его в надёжное место. Потеряешь пароль — потеряешь весь репозиторий, restic в этом смысле безжалостен.

Сам бэкап

Бэкаплю каталог /app целиком — там и compose-рецепты, и данные сервисов, и тот самый дамп БД Nextcloud, который я делаю перед этим.

restic backup /app \
  --exclude-caches \
  --tag daily

Чтобы репозиторий не пух до бесконечности, навожу порядок политикой хранения — оставляю разумную глубину истории и удаляю старьё:

restic forget \
  --keep-daily 7 \
  --keep-weekly 4 \
  --keep-monthly 6 \
  --prune

То есть последние 7 дней, 4 недели и 6 месяцев. Этого с головой, а --prune физически освобождает место от выкинутых снапшотов.

По расписанию

Руками такое делать нельзя — забудешь на второй неделе. У меня это systemd timer (cron тоже годится, дело вкуса), который раз в сутки ночью дёргает скрипт: сначала дамп БД, потом restic backup, потом forget --prune, а в конце пушит копию в offsite-репозиторий командой restic copy.

Грубый набросок строки в cron, если без systemd:

30 3 * * *  /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1

Лог обязательно, потому что молчаливый бэкап — это бэкап, про который ты не знаешь, что он сломался месяц назад.

Самое главное: тест восстановления

А вот теперь то, ради чего весь пост. Бэкап, который ты ни разу не восстанавливал, — это не бэкап, а гипотеза. Я регулярно (раз в пару месяцев) проверяю, что из репозитория реально достаётся файл.

Смотрим, какие снапшоты есть:

restic snapshots

И разворачиваем последний во временную папку:

restic restore latest --target /tmp/restore-test

Дальше глазами проверяю, что внутри лежит то, что должно, и файлы не битые. Один раз так выяснил, что забыл в бэкап положить .env-файлы (исключил лишним паттерном) — и спокойно поправил это в мирное время, а не в три часа ночи под крик «всё пропало».

Вот, собственно, и всё. Скучно, надёжно, проверяемо. Спите спокойно.