--- title: Как программисту завести свой блог date: 2026-10-08T19:22:00+04:00 draft: false tags: - hugo - server summary: Как поднять свой блог --- ## Intro Первое что понадобится это сервер. Просто гуглишь "Debian VPS" и выбираешь из предложенного. **Важно:** Если ты в России, то лучше выбирай хостера и сервер в своей стране. Тогда ищи в яндексе: "аренда виртуального сервера Debian". Пока сервер устанавливается у вас есть около часа, чтобы купить доменное имя. И зарегистрировать аккаунт на cloudflare. Если первое понятно для чего, то cf нужен чтобы спрятать ip адрес вашего сервера от посетителей сайта. Это несомненно повысит безопасность сервера, но делать это нужно до первой привязки домена. Поэтому после покупки доменного имени, нужно в панели управления (там где был куплен домен) сменить NS сервера на сервера, которые выдаст cloudflare после добавления проекта в ЛК. После этого потребуется какое-то время (до 3х дней) на перепарковку доменного имени. Так как об этом должен узнать весь интернет. Зато после этого ни один посетитель не сможет узнать ip вашего сайта, если не допускать утечек, разумеется. Но об этом мы как нибудь потом поговорим. На руках: 1. ip и пароль рута 2. доменное имя припаркованное у cf 3. В настройках cf прописаны записи: 1. yourdomain.com - IP - корневая запись, по имени домена откроется стартовая блога 2. git.yourdomain.com - IP - под-домен для Gitea ## Поехали [Базовая настройка сервера Debian ->](/blog/zero-setup-debian-server/) Ставим докер командой ``` curl -fsSL https://get.docker.com -o install-docker.sh sh install-docker.sh ``` Выходим с сервера, так как теперь нужно все подготовить локально. На свой компьютер ставим hugo из GitHub репозитория, так как в репозитории пакетов Debian он старой версии. https://github.com/gohugoio/hugo/releases Ставим на компьютер Git, чтобы работать с репозиторием. На сервере в докере мы запустим 2 приложения: - **git** - Gitea, сервис с git, типо GitHub - **hugo** - сборщик сайта и Caddy. Caddy отдает готовые страницы и он же единственная точка входа снаружи, через него ходим и в Gitea Hugo из Markdown файлов собирает страницы блога конвертируя все в html. Процесс работы выглядит так: 1. создаешь в репозитории md файл 2. заполняешь его, коммитишь и пушишь 3. сборщик в докере раз в минуту проверяет репозиторий и видит новый коммит 4. собирает сайт и подменяет старую версию на новую Окей, теперь выбери место на компьютере и создай папку `apps`, в ней мы будем хранить конфигурацию Docker приложений на сервере. Итоговая структура будет такая: ``` apps/ ├── git/ │ └── docker-compose.yaml └── hugo/ ├── docker-compose.yaml ├── caddy.conf ├── dot_env ├── .gitignore └── data/ └── builder/ └── build.sh ``` Тут только конфиги. Все данные (репозитории, сертификаты, собранный сайт) живут в named volumes Docker, а не в папках рядом с конфигами. Так `apps` можно спокойно хранить в git и копировать на сервер, не боясь затереть данные. Про бекапы расскажу ниже. Оба приложения общаются через общую сеть `web`. Наружу порты открывает только Caddy. ### git `apps/git/docker-compose.yaml`: ```yaml # Git application for docker-server services: gitea: image: gitea/gitea:28.0-rootless restart: unless-stopped environment: GITEA__server__DOMAIN: git.yourdomain.com GITEA__server__ROOT_URL: https://git.yourdomain.com/ GITEA__server__DISABLE_SSH: "true" GITEA__database__DB_TYPE: sqlite3 GITEA__service__DISABLE_REGISTRATION: "true" volumes: - data:/var/lib/gitea - config:/etc/gitea networks: [ web ] networks: web: external: true volumes: data: config: ``` Берем rootless образ, внутри контейнера Gitea работает не от root. Тут named volumes особенно к месту: Docker при создании тома сам выставит ему владельца из образа, и не надо руками делать `chown`. Порты наружу не открываем, до Gitea ходит только Caddy через сеть `web`. SSH выключен, потому что Cloudflare его не проксирует, а светить ip сервера мы не хотим. Пушить будем по https. Регистрация закрыта, чтобы никто кроме вас не заводил там аккаунты. ### hugo `apps/hugo/docker-compose.yaml`: ```yaml # Сайт с лендингом и блогом на Hugo services: builder: image: ghcr.io/gohugoio/hugo:v0.167.0 restart: unless-stopped user: root entrypoint: [ "sh", "/build.sh" ] environment: REPO_URL: ${REPO_URL} volumes: - ./data/builder/build.sh:/build.sh:ro - src:/src - public:/public networks: [ web ] # чтобы достучаться до git-gitea-1 web: image: caddy:2.10.2-alpine restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./caddy.conf:/etc/caddy/Caddyfile:ro - public:/srv:ro - caddy_data:/data - caddy_config:/config networks: [ web ] # чтобы проксировать на git-gitea-1 networks: web: external: true volumes: src: public: caddy_data: caddy_config: ``` Свой образ собирать не нужно. `builder` это официальный образ Hugo, в нем уже есть git. Версию ставьте ту же, что у вас на компьютере, посмотреть можно командой `hugo version`. `user: root` нужен, потому что в образе по умолчанию обычный пользователь, а volumes Docker создает от root. `web` это Caddy. Он смотрит наружу на 80 и 443, отдает сайт из общего с `builder` тома `public` и проксирует поддомен git на Gitea. В `caddy_data` он хранит свои сертификаты. `apps/hugo/caddy.conf`: ``` yourdomain.com { tls internal encode gzip root * /srv/live file_server handle_errors { rewrite * /404.html file_server } } www.yourdomain.com { tls internal redir https://yourdomain.com{uri} permanent } git.yourdomain.com { tls internal reverse_proxy git-gitea-1:3000 } ``` Сайт отдаем из `/srv/live`, почему именно оттуда, станет понятно из скрипта сборки. `handle_errors` нужен, чтобы на несуществующие страницы показывалась красивая 404 от темы, а не пустой ответ. `www` просто редиректит на основной домен, чтобы у сайта был один адрес. Имя `git-gitea-1` Docker Compose собирает сам из имени папки и имени сервиса: папка `git`, сервис `gitea`, первый экземпляр. Поэтому важно, чтобы папка называлась именно так. `tls internal` значит, что Caddy сам выпустит себе сертификат. Браузер его не увидит, потому что посетители подключаются к Cloudflare, а уже он ходит к нам. Поэтому в панели Cloudflare в разделе SSL/TLS выставите режим **Full**. Не Flexible, иначе трафик от Cloudflare до сервера пойдет без шифрования, и не Full (strict), он не примет такой сертификат. `apps/hugo/dot_env` это шаблон для переменных: ``` REPO_URL=http://git-gitea-1:3000//site.git ``` На сервере копируете его в `.env` и подставляете свое имя пользователя и репозиторий. Сам `.env` в git не попадает, для этого рядом лежит `.gitignore` с одной строкой `.env`. Сборщик клонирует репозиторий напрямую из контейнера Gitea по внутренней сети, без выхода в интернет. Если репозиторий приватный, добавьте токен: `http://:@git-gitea-1:3000//site.git`. Токен выпускается в настройках пользователя Gitea, в разделе Приложения. В этом случае закройте файл от чужих глаз `chmod 600 .env`. `apps/hugo/data/builder/build.sh`: ```sh #!/bin/sh # Раз в минуту: новый коммит -> сборка в /public/next -> подмена /public/live. # Ошибка сборки - остается прежний сайт. # Адрес репозитория берется из REPO_URL при каждом старте: смена в .env + перезаливка = новый источник. cd /src if [ -d .git ]; then git remote set-url origin "$REPO_URL" else git clone --recurse-submodules "$REPO_URL" . || exit 1 fi last='' while true; do if git fetch -q origin HEAD && git reset -q --hard FETCH_HEAD \ && git submodule sync -q --recursive && git submodule update -q --init --recursive; then rev=$(git rev-parse HEAD) if [ "$rev" != "$last" ]; then rm -rf /public/next if hugo --minify -d /public/next; then rm -rf /public/old [ -d /public/live ] && mv /public/live /public/old mv /public/next /public/live last=$rev echo "published $rev" else echo "build of $rev failed, site unchanged" fi fi fi sleep 60 done ``` Главная фишка скрипта в том, что сайт собирается в отдельную папку `next`, и только если сборка прошла успешно, она подменяет рабочую `live`. Если вы запушите коммит с ошибкой, сайт просто останется прежним, а в логе будет видно что сломалось. Предыдущая версия сохраняется в `old`, на случай если нужно быстро откатиться. `git reset --hard` тут специально, а не `git pull`. Если вы сделаете force push или поправите историю, обычный pull сломается, а reset просто возьмет то, что лежит в репозитории. А `set-url` при старте позволяет поменять источник: правите `REPO_URL` в `.env`, перезапускаете контейнер, и сборщик тянет уже из нового места. ## Заливаем на сервер Конфиги готовы, отправляем папку на сервер. `` это имя хоста из `~/.ssh/config`, которое мы настроили в прошлой статье: ``` s0k0l:~$ scp -r apps :/opt/ ``` Перед запуском нужно открыть фаервол для сайта. В прошлой статье мы закрыли все входящее, и там же я предупреждал, что Docker живет по своим правилам. Сейчас разберемся. Открываем на сервере `/etc/nftables.conf`. В цепочке `input` раскомментируем строку для сайта: ``` tcp dport { 80, 443 } accept ``` А цепочку `forward` меняем на такую: ``` chain forward { type filter hook forward priority 0; policy drop; ct state established,related accept ct status dnat accept iifname "docker0" accept iifname "br-*" accept } } ``` Дело в том, что трафик к контейнерам идет не через `input`, а через `forward`. С нашим строгим `policy drop` контейнеры не смогут ни принимать подключения, ни выходить в интернет. Эти правила пропускают трафик на порты, которые Docker опубликовал (у нас это только 80 и 443 у Caddy), и исходящий трафик из самих контейнеров. Все остальное по прежнему закрыто. Проверяем и применяем так же, как в прошлый раз, с таймером на всякий случай: ``` nft -c -f /etc/nftables.conf (sleep 120 && nft flush ruleset) & nft -f /etc/nftables.conf ``` Проверяем что ssh жив, отменяем таймер `kill %1` и перезапускаем Docker: ``` systemctl restart docker ``` Это обязательно. В начале нашего конфига стоит `flush ruleset`, он стирает в том числе правила, которые Docker создал для себя. После рестарта Docker создаст их заново. Запомните это: каждый раз когда перезапускаете nftables, перезапускайте и Docker. ## Запуск Создаем общую сеть, через которую контейнеры будут видеть друг друга: ``` docker network create web ``` Поднимаем Gitea и пока только Caddy, без сборщика. Сборщику нечего клонировать, пока в Gitea нет репозитория: ``` cd /opt/apps/git && docker compose up -d cd /opt/apps/hugo && cp dot_env .env && docker compose up -d web ``` Открываем в браузере `https://git.yourdomain.com`. Gitea покажет страницу первичной настройки, база уже указана в конфиге. Внизу страницы в разделе с аккаунтом администратора создаем себе пользователя. Регистрацию мы закрыли, так что это единственный способ завести аккаунт. В Gitea создаем репозиторий `site`. Теперь на своем компьютере пушим туда сайт: ``` s0k0l:~$ cd my-blog s0k0l:~$ git remote add origin https://git.yourdomain.com//site.git s0k0l:~$ git push -u origin main ``` Если тема подключена как git submodule, то репозиторий темы тоже должен быть доступен сборщику по ссылке из `.gitmodules`. Самое простое, сделать зеркало темы у себя в Gitea и указать в `.gitmodules` его адрес. Подставляем в `/opt/apps/hugo/.env` реальное имя пользователя и запускаем сборщик: ``` cd /opt/apps/hugo && docker compose up -d ``` Смотрим, что он собрал сайт: ``` docker logs -f hugo-builder-1 ``` Если в логе появилось `published <хеш коммита>`, открывайте `https://yourdomain.com`, блог работает. ## Бекапы Так как данные лежат в named volumes, их не видно в `apps`. Посмотреть список можно командой `docker volume ls`. Важные тут два: `git_data` и `git_config`, в них все репозитории и настройки Gitea. Сайт бекапить не нужно, он собирается из репозитория, а сертификаты Caddy выпустит заново. Снять бекап Gitea: ``` docker compose -f /opt/apps/git/docker-compose.yaml stop docker run --rm -v git_data:/data -v git_config:/config -v /root:/backup alpine \ tar czf /backup/gitea-$(date +%F).tgz /data /config docker compose -f /opt/apps/git/docker-compose.yaml start ``` Gitea на время бекапа останавливаем, чтобы база SQLite не записалась наполовину. Готовый архив забираете к себе через `scp`. ## Как теперь писать Весь процесс выглядит так: 1. создаешь пост `hugo new content blog/my-post/index.md` 2. пишешь, смотришь локально через `hugo server` 3. коммитишь и пушишь 4. через минуту пост на сайте Я для удобства завел в репозитории `Makefile`, чтобы не набирать команды руками: ```makefile all: hugo new content $(path) publish: git add . git commit -m 'chore: $(msg)' git push ``` Новый пост `make path=blog/my-post/index.md`, публикация `make publish msg="новый пост"`. На этом все. Сервер, свой git, блог и автодеплой, и все это на одной недорогой VPS без сторонних сервисов, кроме Cloudflare.