Files
site/content/blog/how-to-make-blog/index.md
T
2026-10-10 19:28:57 +04:00

19 KiB
Raw Blame History

title, date, draft, tags, summary
title date draft tags summary
Как программисту завести свой блог 2026-10-08T19:22:00+04:00 false
hugo
server
Как поднять свой блог

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 ->

Ставим докер командой

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:

# 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:

# Сайт с лендингом и блогом на 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/<name>/site.git

На сервере копируете его в .env и подставляете свое имя пользователя и репозиторий. Сам .env в git не попадает, для этого рядом лежит .gitignore с одной строкой .env. Сборщик клонирует репозиторий напрямую из контейнера Gitea по внутренней сети, без выхода в интернет. Если репозиторий приватный, добавьте токен: http://<name>:<token>@git-gitea-1:3000/<name>/site.git. Токен выпускается в настройках пользователя Gitea, в разделе Приложения. В этом случае закройте файл от чужих глаз chmod 600 .env.

apps/hugo/data/builder/build.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, перезапускаете контейнер, и сборщик тянет уже из нового места.

Заливаем на сервер

Конфиги готовы, отправляем папку на сервер. <name> это имя хоста из ~/.ssh/config, которое мы настроили в прошлой статье:

s0k0l:~$ scp -r apps <name>:/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/<name>/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, чтобы не набирать команды руками:

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.