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

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

Несколько лет я хранил фотки в 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-файлы (исключил лишним паттерном) — и спокойно поправил это в мирное время, а не в три часа ночи под крик «всё пропало».

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

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

Поставил Nextcloud, по своему же правилу — отдельным стеком в /app/nextcloud/. Связка стандартная: сам Nextcloud + база (взял MariaDB) + Redis для кэша. Всё в одном compose.yml, данные смонтированы в ./data, которая лежит на SATA-диске под хранилище.

Автозагрузка фото с телефона

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

Я ещё включил, чтобы заливалось только по wifi и оставлял оригиналы на телефоне до подтверждения — мобильный трафик и нервы целее.

По объёму: у меня сейчас наехало около 180 ГБ фото и видео, и это вполне комфортно лежит на отдельном диске. Видео жрёт несоизмеримо больше фоток, так что если вы снимаете 4K — закладывайте место с запасом.

Грабли, на которые я наступил

Без них не обошлось, записываю, чтобы не повторять.

Права на каталог data. Классика. Контейнер Nextcloud работает под пользователем веб-сервера (uid 33, www-data), а каталог с данными после первого запуска принадлежал кому попало. Симптом — Nextcloud ругается, что не может писать. Лечится приведением владельца в порядок:

sudo chown -R 33:33 /app/nextcloud/data

После этого жалобы пропали. Если потом руками кидать файлы в data мимо Nextcloud — он их не увидит, пока не сделаешь files:scan, но это уже другая история.

Генерация превью. Когда залил всю гору фоток разом, веб-морда стала открываться мучительно долго: Nextcloud пытался на лету генерировать превьюшки для тысяч картинок. Решается тем, чтобы прогнать их пачкой заранее, через occ внутри контейнера:

docker compose exec -u www-data nextcloud \
  php occ preview:generate-all

Один раз помучился — дальше галерея листается бодро. Тяжёлые форматы (HEIC, видео) требуют, чтобы в образе были нужные библиотеки; в актуальном официальном образе с этим уже норм.

Приложение Memories. Поставил его поверх — это нормальная лента «фото по датам», к которой привыкаешь в облаках. Хорошая штука, но у неё свой индекс, который надо один раз прогнать (memories:index), иначе лента пустая и ты сидишь и не понимаешь, почему. Прогнал — заработало.

Бэкап базы

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

Поэтому дамп базы у меня делается отдельно и регулярно:

docker compose exec -T db \
  mysqldump -u nextcloud -p"$DB_PASS" nextcloud \
  > /app/nextcloud/backup/db-$(date +%F).sql

А вот как этот дамп вместе с фотками уезжает в нормальный бэкап по правилу 3-2-1 — будет в следующем посте. Потому что хранилище без бэкапа — это не хранилище, а бомба замедленного действия.

Когда сервисов на коробке становится больше трёх, наступает момент истины: либо у тебя есть система, либо у тебя бардак, в котором ты через полгода не вспомнишь, где чьи данные. У меня бардак уже был, поэтому в этот раз я завёл правило и держусь за него зубами.

Правило ровно одно: один docker compose стек = одна папка в /app/<сервис>. Всё, что относится к сервису, живёт внутри этой папки и нигде больше.

Как выглядит на диске

/app
├── writefreely/
│   ├── compose.yml
│   ├── .env
│   └── data/
├── nextcloud/
│   ├── compose.yml
│   ├── .env
│   └── data/
└── gitea/
    ├── compose.yml
    ├── .env
    └── data/

Никаких именованных докер-томов, разбросанных в недрах /var/lib/docker, которые потом фиг найдёшь. Все данные я монтирую относительными путями внутрь папки сервиса — в подкаталог data/. Хочу посмотреть, что сервис нажил — иду в его папку, и там всё.

Из чего состоит стек

Типичный compose.yml у меня выглядит так:

services:
  writefreely:
    image: writeas/writefreely:latest
    container_name: writefreely
    restart: unless-stopped
    env_file: .env
    ports:
      - "127.0.0.1:8080:8080"
    volumes:
      - ./data:/data

Несколько вещей, которые я делаю всегда и осознанно:

  • restart: unless-stopped на каждом сервисе. После ребута коробки (или скачка питания) всё поднимается само. Но если я сам остановил контейнер руками — он остаётся остановленным и не воскресает у меня за спиной. always ведёт себя навязчивее, поэтому именно unless-stopped.
  • Все секреты — в .env, не в compose.yml. Пароли БД, токены, ключи. Сам compose.yml тогда не стыдно показать кому угодно.
  • Порты вешаю на 127.0.0.1, наружу публикую отдельно через единую точку входа. Внутрь докера снаружи напрямую никто не лезет.

.env рядом, и в нём примерно вот это:

# /app/writefreely/.env
DOMAIN=blog.local
DATABASE_PASSWORD=...

Восстанавливаемость — ради неё всё и затевалось

Главная мысль всей этой раскладки: коробку я должен уметь собрать заново с нуля за вечер. Не «вспомнить, как было», а буквально развернуть.

Для этого мне нужны две вещи: сами compose.yml с .env (это рецепт) и data/ (это содержимое). Рецепты весят копейки и меняются редко, поэтому я их версионирую и складываю отдельно. Грубо — собрать все compose-файлы в одно место:

mkdir -p ~/server-config/app
rsync -av --include='*/' \
  --include='compose.yml' --include='.env' \
  --exclude='*' \
  /app/ ~/server-config/app/

Эту папку ~/server-config я кладу в git и в бэкап. Получается, что вся конфигурация сервера — это десяток текстовых файлов, которые читаются глазами и восстанавливаются за минуты.

Данные из data/ — отдельная история, они большие и про них будет отдельный разговор про бэкапы. Но рецепт от содержимого я держу раздельно сознательно: потерять конфиг и потерять данные — это две очень разные катастрофы, и готовиться к ним надо по-разному.

Скучно? Скучно. Но именно скучные системы переживают переезд на новое железо без седых волос.

Обещал написать про железо — пишу. Спойлер: ничего пафосного, и это сознательно.

Долгое время «домашний сервер» у меня жил в голове как стойка, гул вентиляторов и счёт за электричество размером с аренду. Потом я посмотрел, сколько реально потребляет то, что мне нужно, и понял, что городить огород незачем. Мне нужна тихая коробка, которая работает 24/7, не греет комнату и не пугает соседей по счётчику.

Что в итоге взял

Взял б/у Dell OptiPlex Micro — это такой мини-корпус размером чуть больше книжки. На вторичке их полно, потому что корпорации списывают их пачками после офисного цикла. Внутри обычный десктопный x86, а не мобильный огрызок.

Конфигурация после небольшого апгрейда:

  • CPU: Intel Core i5 (4 ядра, вполне хватает);
  • RAM: было 8 ГБ, докинул до 16 ГБ SO-DIMM (минут пять работы);
  • системный диск: NVMe 512 ГБ в M.2 слот;
  • плюс одна SATA 2.5” под данные — в этих корпусах есть посадочное место под 2.5” диск, чем я и воспользовался.

Альтернативой я всерьёз рассматривал свежие мини-ПК на Intel N100 — они новые, холодные, и идут с нормальным питанием из коробки. Если не хочется возиться с б/у, это отличный вариант, по деньгам сопоставимо. Я просто люблю запах списанного корпоративного железа.

Почему не Raspberry Pi

Этот вопрос мне задают чаще всего, поэтому отвечу один раз и со ссылкой на этот абзац.

«Малинку» я очень уважаю, но для домашнего сервера она мне не зашла по трём причинам:

  1. Архитектура. Тут x86-64, а значит весь софт и все докер-образы просто работают. Не надо искать arm-сборки и спотыкаться об «а вот это под arm не собрали».
  2. Диски. NVMe и SATA напрямую, а не microSD, которая дохнет от постоянной записи, и не USB-костыли. Для хранилища это критично.
  3. Цена. Бэушный мини-ПК с уже стоящей памятью и местом под диск выходит сопоставимо с Pi в сборе (плата + нормальный корпус + питание + накопитель), но сразу даёт полноценную машину.

Pi прекрасен как маленький контроллер чего-нибудь. Но как рабочая лошадка с дисками — мини-ПК удобнее.

Потребление

Главный приятный сюрприз — аппетит. Замерил ваттметром из розетки:

  • в простое — около 10–15 Вт;
  • под нагрузкой (когда что-то реально молотит) — поднимается до ~30–35 Вт, но это редко.

15 Вт в простое — это меньше, чем светодиодная лампочка в люстре. За такое не жалко платить за круглосуточную работу.

Система

Поставил Ubuntu Server 24.04 LTS. Без графики, чисто консоль. Выбор простой: LTS до 2029-го, гигантское комьюнити, любой гайд в интернете по умолчанию написан под Ubuntu/Debian. Когда что-то ломается, ответ находится за тридцать секунд.

Разметку сделал максимально скучную: NVMe под систему, SATA-диск отдельным разделом под данные сервисов, который потом монтирую в докер-тома. Всё, дальше уже софт.

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