В этой инструкции я расскажу, как поднять свой сайт с использованием генератора статических сайтов Hugo. А также покажу процесс создания контента с помощью Obsidian и его автоматической публикации через CI/CD👨💻.
🖐️Эй!
Подписывайтесь на наш телеграм @r4ven_me📱, чтобы не пропустить новые публикации на сайте😉. А если есть вопросы или желание пообщаться по тематике — заглядывайте в Вороний чат @r4ven_me_chat🧐.
Предисловие: зачем это всё?
Многие пользователи Linux в процессе изучения часто заводят локальную базу знаний/заметок, которая нередко перерастает в блог или что-то подобное, с доступом из интернета.
Можно сказать, что мой сайт является наглядным примером такого подхода😊. Совсем недавно я перевел его с WordPress на Hugo. Поэтому можно считать эту заметку закреплением материала🙄.
Также немаловажным аргументом для написания этой статьи были просьбы подписчиков из нашего Telegram канала о создании подобного материала.
Теперь немного про виновника торжества. Hugo🥳 - это генератор статических сайтов с открытым исходным кодом, написанный на Go. Его основная задача преобразовать шаблоны (HTML, CSS, JS) и контент (Markdown) в быстрый, безопасный и готовый к размещению в интернете веб-сайт.
В отличие от динамических систем, таких как WordPress, Hugo не использует базу данных и не требует постоянно работающего бэкенд-сервиса. Он просто заранее создает все страницы сайта как простые HTML-файлы, которые отдает веб сервер, например Nginx/Angie, Apache, Traefik и т.п. Работает быстро, сохраняя при этом высокий уровень безопасности за счет уменьшения количества точек отказа и компрометации.
К тому же использование Hugo - отличный повод попробовать современные подходы и инструменты👨💻. Сейчас модно называть разные концепции “что-то там as Code”. Так вот Hugo - это как раз и есть инструмент реализации, так называемого Docs as Code.
Вы пишите посты в Markdown формате, которые хранятся в виде обычных текстовых файлов. Их вы кладёте в Git репозиторий , настраиваете CI/CD, который инициирует сборку статического сайта и скармливает полученные файлы веб серверу.
В этой статье я постарался описать процесс настройки и запуска сайта на Hugo просто и доступно, как сумел. Если у вас возникнут вопросы или столкнётесь с какими-то проблемами, прошу, не стесняйтесь задать вопрос в нашем Вороньем чате. У нас там довольно дружелюбное микросообщество IT гурманов😇. И я обязательно постараюсь вам помочь в свободное время.
Схема работы
Теперь коротко про то, что мы сегодня настроим и как это будет работать. Схема работы будущей системы публикации контента следующая:
- Редактирование
.mdфайлов; commitиpushизменений в удаленный репозиторий;- Запуск процесса CI/CD (GitHub Actions);
- Build: сборка файлов сайта и их валидация;
- Deploy: выполнение SSH команды (запуск скрипта) на сервере, где запущен сайт;
- Notify: проверка статуса и отправка уведомления в Telegram об успехе/провале деплоя.
Ниже представлена упрощенная визуализация описанной выше схемы:

Предварительные требования
Для реализации нашего плана нам понадобятся:
- Установленный и настроенный Linux сервер, где будет работать сайт;
- Установленный и запущенный Docker engine на сервере;
- Доменное имя + DNS запись типа A/AAA, указывающая на публичный IP, вашего сервера;
- Аккаунт на GitHub для настройки CI/CD;
- Аккаунт в Telegram, для отправки и получения уведомлений о статусе деплоя.
Вводные данные
Ниже вводные данные, которые будут использоваться в статье:
| Key | Value |
|---|---|
| Имя сервера | hugo.r4ven.me |
| SSH порт сервера | 2222 |
| ОС сервера | Debian 13 |
| Имя пользователя | ivan |
| Имя локальной машины | desktop.lan |
| Docker образ Hugo | debian-0.151.0 |
| Docker образ Angie | 1.10.2-alpine |
Рекомендую сразу открыть два терминала или две вкладки: в первой мы подключимся к удаленному серверу, hugo.r4ven.me в моём примере, а во второй наша локальная машина (desktop.lan в моём примере).
Подключаемся к нашему серверу по SSH и переходим в привилегированный режим root, например, через sudo:
ssh -p 2222 ivan@hugo.r4ven.me
sudo -sЗапуск Angie и Hugo
☝️Следующие действия выполняются на удалённом сервере hugo.r4ven.me от имени root пользователя.
Создаём нужную структуру директорий в /opt и переходим в рабочую:
mkdir -vp /opt/hugo/{hugo,angie}_data && cd /opt/hugo/
Теперь создаём compose файл для сервисов Angie и Hugo:
nvim docker-compose.ymlНаполняем:
---
services:
angie:
#image: docker.angie.software/angie:latest
image: docker.angie.software/angie:1.10.2-alpine
container_name: angie
restart: on-failure
stop_grace_period: 1m
deploy: &default_deploy
resources:
limits:
cpus: '0.70'
memory: 512M
reservations:
cpus: '0.2'
memory: 256M
logging: &default_logging
driver: json-file
options:
max-size: "50m"
max-file: "5"
hostname: angie
environment:
- TZ=Europe/Moscow
#command: ["angie", "-g", "daemon off;"]
volumes:
- ./angie_data/angie.conf:/etc/angie/angie.conf:ro
- ./angie_data/conf.d/:/etc/angie/conf.d/:ro
- ./angie_data/certs/:/var/lib/angie/acme/
- ./hugo_data/hugo.r4ven.me/public/:/usr/share/angie/html/:ro
ports:
- "80:80"
- "443:443"
- "443:443/udp"
hugo:
#image: docker.io/hugomods/hugo
image: hugomods/hugo:debian-0.151.0
container_name: hugo
restart: on-failure
stop_grace_period: 1m
deploy: *default_deploy
logging: *default_logging
hostname: hugo
environment:
- TZ=Europe/Moscow
working_dir: /src/hugo.r4ven.me
volumes:
- ./hugo_data/:/src/
command: ["hugo", "--minify", "--gc"]Замените доменное имя на своё
sed 's|hugo.r4ven.me|example.com|g' ./docker-compose.ymlОбратите внимание
Контейнер, описанный в файле docker-compose.yml намеренно ограничен в аппаратных ресурсах (директива deploy) на использование cpus: '0.70' и memory: 512M, т.е максимально разрешенное использование CPU составляет 70% одного ядра и 512 мб RAM. А также явно заданы ограничения на хранения логов контейнеров: 5 файлов по 50 мб. При необходимости скорректируйте данные параметры в соответствии со своими потребностями. Подробнее про лимиты ресурсов сервисов при использовании docker compose читайте тут, а про логирование тут.
Настройка и запуск Angie
Далее создадим файл конфигурации веб-сервера angie.conf:
nvim ./angie_data/angie.confuser angie;
worker_processes auto;
worker_rlimit_nofile 65536;
load_module modules/ngx_http_zstd_filter_module.so;
load_module modules/ngx_http_zstd_static_module.so;
load_module modules/ngx_http_brotli_filter_module.so;
load_module modules/ngx_http_brotli_static_module.so;
events {
worker_connections 65536;
}
http {
include /etc/angie/mime.types;
default_type application/octet-stream;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 20;
server_tokens off;
brotli on;
brotli_comp_level 5;
zstd on;
zstd_comp_level 7;
gzip on;
gzip_comp_level 6;
gzip_types text/plain text/css application/javascript application/json application/xml image/svg+xml;
# application/rss+xml
access_log /var/log/angie/access.log;
error_log /var/log/angie/error.log notice;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "accelerometer=(), camera=(), microphone=()" always;
add_header X-Frame-Options "DENY" always;
# DNS-резолвер для ACME
resolver 8.8.8.8 8.8.4.4 ipv6=off;
acme_client le https://acme-v02.api.letsencrypt.org/directory
email=kar-kar@r4ven.me
key_type=ecdsa
renew_before_expiry=30d
# renew_on_load=on
;
server {
listen 80;
server_name hugo.r4ven.me;
location /.well-known/acme-challenge/ {
root /usr/share/angie/html;
}
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
http3 on;
server_name hugo.r4ven.me;
acme le;
ssl_certificate $acme_cert_le;
ssl_certificate_key $acme_cert_key_le;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:50m;
ssl_session_timeout 1d;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
root /usr/share/angie/html;
index index.html;
location /.well-known/acme-challenge/ {
root /usr/share/angie/html;
}
location ~* \.(?:css|js|mjs|png|jpg|jpeg|gif|webp|svg|ico|ttf|otf|woff|woff2)$ {
access_log off;
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location = / {
add_header Cache-Control "public, max-age=3600, stale-while-revalidate=300";
try_files /index.html =404;
}
location ~* \.html$ {
add_header Cache-Control "public, max-age=3600, stale-while-revalidate=300";
try_files $uri =404;
}
# gzip_static on;
# brotli_static on;
open_file_cache max=10000 inactive=60s;
open_file_cache_valid 30s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
error_page 404 /404.html;
error_page 500 502 503 504 /500.html;
}
include /etc/angie/conf.d/*.conf;
}📝 Примечание
Данная конфигурация Angie представляет собой оптимизированный веб-сервер для обслуживания статического сайта. Особенностью Angie, в отличие от Nginx, является встроенный ACME-клиент, который автоматически получает и обновляет TLS-сертификаты от Let’s Encrypt. Конфиг настроен на работу с HTTP/2 и HTTP/3, включает многоуровневое сжатие через gzip, Brotli и Zstandard для максимальной производительности, а также реализует строгие security-заголовки и кэширование статического контента.
Замените следующие параметры в конфиге на свои:
email=kar-kar@r4ven.me;server_name hugo.r4ven.me;- в секцииserver(http)server_name hugo.r4ven.me;- в секцииserver(https)
sed 's|email=kar-kar@r4ven.me;|email=user@example.com;|' \
./angie_data/angie.conf
sed 's|server_name hugo.r4ven.me;|server_name example.com;|g' \
./angie_data/angie.confПриступаем к запуску Angie и просмотру логов:
docker compose up -d angie
docker compose ps
docker compose logs -fDocker скачает нужный образ и запустит контейнер. При первом запуске angie запросит TLS сертификаты у lets encrypt. В выводе контейнера вы должны увидеть подобное:

Значит все Ok.
Проверяем физическое наличие сертификатов:
ls -lR ./angie_data/certs/
Если всё запустилось успешно, проверяем работу веб-сервера и смотрим данные TLS сертификата:
curl -I https://hugo.r4ven.me
openssl s_client -connect hugo.r4ven.me:443 < /dev/null 2> /dev/null | openssl x509 -text | head -n 20
Прекрасно. Идём дальше.
Настройка и запуск Hugo
Первым делом нужно определиться с темой сайта, коих существует достаточно много. Выбрать можно на оф. сайте: https://themes.gohugo.io/. В этой статье я буду использовать тему, которая используется для моего сайта r4ven.me - Narrow. Посмотреть Live demo можно тут. Документация темы тут.
Запускаем контейнер с Hugo и создаём проект нового сайта (замените адрес hugo.r4ven.me на свой):
docker compose run --rm -w /src hugo new site hugo.r4ven.me
Клонируем репозиторий темы Narrow:
git clone https://github.com/tom2almighty/hugo-narrow.git \
./hugo_data/hugo.r4ven.me/themes/hugo-narrow
cp -r ./hugo_data/hugo.r4ven.me/themes/hugo-narrow/exampleSite/* \
./hugo_data/hugo.r4ven.me/
Подчищаем лишнее:
rm -f ./hugo_data/hugo.r4ven.me/hugo.toml
rm -rf ./hugo_data/hugo.r4ven.me/themes/hugo-narrow/.gitИ указываем свой адрес в конфиг файле Hugo:
sed -i "s|^baseURL.*|baseURL: 'https://hugo.r4ven.me'|" \
./hugo_data/hugo.r4ven.me/hugo.yamlТеперь добавим пару тестовых заметок прямо в терминале:
cat << EOF > ./hugo_data/hugo.r4ven.me/content/posts/first-post.md
---
title: "My First Post"
date: 2025-06-13
draft: false
categories: ["Blog"]
tags: ["Hugo", "Tutorial"]
---
## Hello, world!
EOFcat << EOF > ./hugo_data/hugo.r4ven.me/content/posts/second-post.md
---
title: "My second Post"
date: 2025-07-20
draft: false
categories: ["Test"]
tags: ["Hugo", "Howto"]
---
## Hello, friend!
EOFПроверяем:
ls -l ./hugo_data/hugo.r4ven.me/content/posts/
📝Тут же видно, что тема уже содержит несколько демонстрационных заметок.
Как вы обратили внимания, вначале каждой заметки есть некоторые метаданные, так называемый Front matter.
Теперь запустим обновление/генерацию нашего сайта:
docker compose up hugoКоманда выполнит генерацию и, если все ок, покажет итоговый статус файлов сайта:

Проверяем работу сайта в консоли:
curl https://hugo.r4ven.me/posts/first-post/
curl https://hugo.r4ven.me/posts/second-post/И конечно же в браузере https://hugo.r4ven.me:

Site is up!
💡Основной файл настроек сайта: hugo.yaml. В моём примере: /opt/hugo/hugo_data/hugo.r4ven.me/hugo.yaml.
Опционально: настройка запуска Hugo с помощью Systemd
Для удобного управления всем стеком Compose я обычно использую Systemd.
Создаём Systemd unit сервис:
systemctl edit --full --force hugo.serviceНаполняем примерно таким содержимым:
[Unit]
Description=Hugo service
Requires=docker.service
After=docker.service
[Service]
Restart=on-failure
RestartSec=5
ExecStart=/usr/bin/docker compose -f /opt/hugo/docker-compose.yml up
ExecStop=/usr/bin/docker compose -f /opt/hugo/docker-compose.yml down
[Install]
WantedBy=multi-user.target
Останавливаем текущий контейнер и запускаем его уже через Systemd:
docker compose down
systemctl enable --now hugo
Проверяем статус контейнера/сервиса:
docker compose ps
systemctl status hugo
journalctl -fu hugo
Теперь можно выполнять systemctl start|stop|restart hugo для управления.
Настройка CI/CD на примере GitHub Actions
Чтож. Сайт работает, но обновлять/наполнять его контентом через консоль - задача для энтузиастов. У нас таких нет, поэтому приступаем к настройке автоматики.
☝️Следующие действия выполняются на удалённом сервере hugo.r4ven.me от root пользователя.
Прежде всего создадим SSH ключи, которые будут использоваться для доступа к репозиторию во время деплоя:
mkdir -vp ~/.ssh/ && chmod 700 ~/.ssh/
ssh-keygen -q -N "" -t ed25519 -f ~/.ssh/id_ed25519_github_repo
ls -l ~/.ssh/
Сразу же укажем SSH использовать данный ключ при обращении к Github:
cat << EOF >> ~/.ssh/config
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_github_repo
IdentitiesOnly yes
EOFИ прежде, чем мы перейдём к следующему шагу, выведем и скопируем наш публичный SSH ключ:
cat ~/.ssh/id_ed25519_github_repo.pub
Мы добавим его в качестве “deploy key” для доступа к репозиторию.
Переходим на github, входим в свой аккаунт (или создаём новый). Далее создаём новый репозиторий:

Указываем название, делаем его приватным и нажимаем “Создать”:

Теперь переходим в настройки репозитория:

И нажимаем “Добавить ключ деплоя”:

Также указываем имя, сам публичный SSH ключ в поле “Key” и включаем “Allow write access”:
💡После завершения всей настройки можете заменить на ключ без write доступа, при необходимости.

Теперь возвращаемся в консоль нашего сервера и настраиваем git.
Устанавливаем если его еще нет и переходим в директорию сайта:
apt update && apt install -y git
cd /opt/hugo/Инициализируем новый репозиторий, настраиваем исключения:
git init
echo 'hugo_data/hugo.r4ven.me/public/' > .gitignore
echo 'hugo_data/hugo.r4ven.me/content/.obsidian/' >> .gitignore
echo 'hugo_data/hugo.r4ven.me/.hugo_build.lock' >> .gitignore
echo 'angie_data/certs/' >> .gitignore📝 Примечание
Будет такое предупреждение, не обращаем на него внимания:
Создаём файлы-пустышки в каждой директории проекта (чтобы их можно было отправить в GitHub), добавляем файлы в индекс и фиксируем изменения (комитим):
find ./hugo_data/hugo.r4ven.me/ -type d -empty -exec touch {}/.gitkeep \;
git add ./
git commit -m "Hugo initial commit"
В конце синхронизируем локальный репозиторий с удаленным:
git branch -M main
git remote add origin git@github.com:r4ven-me/hugo.git
git push -u origin main☝️Замените r4ven-me/hugo.git на ваш аккаунт и репозиторий.

Проверяем репо в вебе:

Всё хорошо. Продолжаем настройку.
☝️Следующие действия выполняются на локальной машине desktop.lan.
Теперь выполняем настройку на клиентской машине с которой планируется заниматься наполнением сайта.
Создаём папку проекта и клонируем наш удалённый репозиторий:
☝️Подразумевается, что доступ с клиентской машины к вашим репозиториям на GitHub уже настроен.
git clone git@github.com:r4ven-me/hugo.git ~/Hugo
mkdir -p ~/Hugo/.github/workflows
cd ~/Hugo
В этой директории создаём специальный файл описания CI/CD пайплайна, который автоматически считывается самим GitHub при пуше в репо:
nvim .github/workflows/deploy.yml---
name: Deploy Hugo site
on:
push:
branches: [ "main" ]
workflow_dispatch:
env:
HUGO_VERSION: 0.151.0
defaults:
run:
shell: bash
jobs:
build:
name: Build and test Hugo site
runs-on: ubuntu-latest
defaults:
run:
working-directory: hugo_data/hugo.r4ven.me
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Set up Hugo
uses: peaceiris/actions-hugo@v3
with:
hugo-version: ${{ env.HUGO_VERSION }}
extended: true
- name: Build site
run: hugo --minify --gc
- name: Validate Hugo output
run: |
# Проверка папки
if [ ! -d "public" ]; then
echo "❌ No 'public' directory!"
exit 1
fi
# Проверка index.html
if [ ! -f "public/index.html" ]; then
echo "❌ No index.html generated!"
exit 1
fi
# Проверка размера
if [ ! -s "public/index.html" ]; then
echo "❌ index.html is empty!"
exit 1
fi
# Проверка количества файлов
FILE_COUNT=$(find public -type f | wc -l)
if [ "$FILE_COUNT" -lt 5 ]; then
echo "❌ Too few files ($FILE_COUNT) in public/ — probably build failed!"
exit 1
fi
echo "✅ Hugo output looks OK: $FILE_COUNT files generated."
- name: Check generated HTML
run: |
sudo apt install -y ruby ruby-dev
sudo gem install html-proofer
sudo htmlproofer ./public --disable-external --allow-missing-href
deploy:
name: Deploy to remote server
needs: build
if: success()
runs-on: ubuntu-latest
steps:
- name: Run a multi-line script
run: |
mkdir -p ~/.ssh
echo "${{ secrets.KEY }}" > ~/.ssh/id_ed25519
chmod 700 ~/.ssh/
chmod 600 ~/.ssh/id_ed25519
echo -e "Host *\nStrictHostKeyChecking no\n" > ~/.ssh/config
ssh ${{ secrets.HOST }} -p ${{ secrets.PORT }} -l ${{ secrets.USER }}
rm -rf ~/.ssh
notify:
runs-on: ubuntu-latest
needs: [build, deploy]
if: always()
steps:
- name: Send Telegram summary
uses: appleboy/telegram-action@master
with:
to: ${{ secrets.TELEGRAM_ID }}
token: ${{ secrets.TELEGRAM_TOKEN }}
message: |
🚀 **CI/CD Summary**
Repository: ${{ github.repository }}
Commit: ${{ github.sha }}
Author: ${{ github.actor }}
Message: ${{ github.event.head_commit.message }}
--------------------------------
🧱 Build: ${{ needs.build.result }}
📦 Deploy: ${{ needs.deploy.result }}Обязательно меняем имя домена на своё:
sed -i 's|hugo.r4ven.me|example.com|g' .github/workflows/deploy.ymlОписание пайпалайна (GitHub actions)
Данный пайп запускается при каждом push в ветку main (или при ручном запуске в вебе). Состоит пайп из трех этапов: build, deploy и notify.
Этап build собирает сайт Hugo на серверах GitHub, проверяет сгенерированный HTML с помощью htmlproofer и валидирует структуру выходных файлов.
Если сборка со всеми проверками прошли успешно, выполняется этап deploy, который представляет собой запуск скрипта на нашем удаленном сервере через SSH (про него чуть позже).
Этап notify отправляет уведомление в Telegram с информацией о сборке и развертывании, независимо от их успеха или неудачи.
В этом пайпе также используются внешние actions:
- actions/checkout@v4 - клонирует репозиторий в runner, чтобы Hugo имел доступ к исходникам сайта
- peaceiris/actions-hugo@v3 - устанавливает Hugo указанной версии в runner
Теперь необходимо создать реквизиты доступа к нашему удалённому серверу по SSH для этапа deploy из пайплайна.
На клиентской машине генерируем новый ключ SSH и выводим в терминал его ПРИВАТНУЮ часть:
ssh-keygen -q -N "" -t ed25519 -f ~/.ssh/id_ed25519_github_cicd
cat ~/.ssh/id_ed25519_github_cicd-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----Теперь переходим в настройки репозитория, в раздел секретов и переменных и добавляем новые secrets:

| Key | Value |
|---|---|
| USER | ivan |
| HOST | hugo.r4ven.me |
| PORT | 2222 |
| KEY | —–BEGIN OPENSSH PRIVATE KEY—– … —–END OPENSSH PRIVATE KEY—– |
| TELEGRAM_ID | 12345678 |
| TELEGRAM_TOKEN | 123456qwerty |

☝️Важно
В значении KEY чётко указывайте содержимое приватного ключа, так как оно вывелось в терминале.
Под спойлером короткая инструкция, как получить токена бота (TELEGRAM_TOKEN) и id чата (TELEGRAM_ID):
Клик на спойлер
Для создания нового телеграм бота и получения телеграм ID выполните следующие шаги.
1) В поиске телеграм находим бота для создания других ботов @BotFather и делаем, как на скриншоте:

В моём примере:
- Адрес бота:
@r4ven_notes_bot - Токен бота:
7894620308:AAHDi2w2RsgkIFE-qh0nujntiGruoTIQeuA
2) При нажатии на адрес бота попадём в чат с ним, тут нужно нажать «Старт»:

3) Теперь в поиске телеграм ищем @myidbot с помощью которого узнаём наш телеграм ID:

Пример Telegram ID: 1234567890
Вот такие секреты у вас должны быть:

Теперь выводим в терминале содержимое ПУБЛИЧНОГО ключа:
cat ~/.ssh/id_ed25519_github_cicd.pubssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGvDu1omD1WwG1K68/mkvnBw/d4xRpREHDH4ytMDOl20И переходим к настройке запуска скрипта при подключении на этапе deploy в пайплайне.
☝️Следующие действия выполняются на удалённом сервере hugo.r4ven.me от имени обычного пользователя (ivan в моём случае).
Теперь на сервере создаём специально настроенный authorized_keys для SSH:
mkdir -p ~/.ssh/
chmod 700 ~/.ssh/
echo 'command="sudo /opt/hugo/deploy.sh",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGvDu1omD1WwG1K68/mkvnBw/d4xRpREHDH4ytMDOl20' >> ~/.ssh/authorized_keysГде в место ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGvDu1omD1WwG1K68/mkvnBw/d4xRpREHDH4ytMDOl20
укажите свой публичный ключ, который вывели в терминале на клиентской машине шагом ранее.
📝 Примечание
Строка в файле authorized_keys содержит публичный ключ SSH и специфическую команду. Суть в том, что при подключении по SSH с использованием соответствующего приватного ключа, вместо открытия интерактивной оболочки, будет выполнена только указанная команда, в данном примере sudo /opt/hugo/deploy.sh. Опции no-port-forwarding, no-X11-forwarding, no-agent-forwarding, no-pty ограничивают функциональность SSH соединения, запрещая перенаправление портов, X11, агента и выделение псевдотерминала, что повышает безопасность и позволяет использовать SSH только для выполнения конкретной задачи, в данном случае - деплоя сайта с помощью скрипта deploy.sh.
Теперь давайте настроим сервер, чтобы команда sudo /opt/hugo/deploy.sh могла успешно выполняться.
☝️Следующие действия выполняются на удалённом сервере hugo.r4ven.me от имени root пользователя.
Создаём sudoers файл:
visudo -f /etc/sudoers.d/90_hugoНаполняем:
ivan ALL=(root) NOPASSWD: /opt/hugo/deploy.sh☝️Важно
Вместо ivan укажите имя вашего пользователя.

❗️ Осторожно
Будьте очень внимательны при редактировании файлов sudo. Если допустите синтаксическую ошибку, можете потерять доступ к привилегированному режиму через sudo. Всегда держите второй терминале с УЗ root открытым на всякий случай.
Теперь создадим на сервере тестовый deploy.sh:
echo -e '#!/usr/bin/bash\necho Pong' > /opt/hugo/deploy.sh
chmod 700 /opt/hugo/deploy.shДавайте протестируем его работу с клиента, на котором мы сгерерировали CI/CD ключ: id_ed25519_github_cicd.
☝️Следующие действия выполняются на локальной машине desktop.lan.
Пробуем подключиться к серверу:
ssh -p 2222 -i ~/.ssh/id_ed25519_github_cicd ivan@hugo.r4ven.meВ ответ вы должны получить Pong из нашего deploy.sh:
PTY allocation request failed on channel 0
Pong
Connection to hugo.r4ven.me closed.Если это так, то мы все настроили корректно.
Теперь давайте напишем полноценный Bash скрипт для деплоя Hugo в директории проекта ~/Hugo:
nvim ./deploy.shНа полняем:
#!/usr/bin/env bash
# Безопасные параметры выполнения скрипта:
# -E: trap наследуется функциями
# -e: выход при любой ошибке
# -u: ошибка при использовании неинициализированных переменных
# -o pipefail: возврат кода ошибки последней команды в конвейере
set -Eeuo pipefail
# Явное определение PATH, чтобы избежать проблем с поиском бинарников
export PATH="/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin"
# Переменные проекта
PROJECT_NAME="hugo" # Имя проекта/сервиса Hugo
PROJECT_WEB="angie" # Имя веб-сервера проекта
PROJECT_DIR="/opt/hugo" # Директория проекта
# Параметры логирования
LOG_TO_STDOUT=1 # вывод логов в stdout
LOG_TO_FILE=0 # логирование в файл (<script_name>.log)
LOG_TO_SYSLOG=0 # логирование в syslog с тегом <script_name>
# Основные переменные скрипта
SCRIPT_PID=$$ # PID текущего скрипта
# Абсолютный путь к директории скрипта
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" &> /dev/null && pwd -P)
SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")" # Имя скрипта
SCRIPT_LOG="${SCRIPT_DIR}/${SCRIPT_NAME%.*}.log" # Файл лога
SCRIPT_LOG_PREFIX='[%Y-%m-%d %H:%M:%S.%3N]' # Формат времени для логов
SCRIPT_LOCK="${SCRIPT_DIR}/${SCRIPT_NAME%.*}.lock" # Файл блокировки
# Функция очистки ресурсов при завершении или ошибках
cleanup() {
trap - SIGINT SIGTERM SIGHUP SIGQUIT ERR EXIT # сброс ловушек
# Закрываем дескриптор блокировки, если был открыт
[[ -n "${fd_lock-}" ]] && exec {fd_lock}>&-
# Удаляем файл блокировки, если он принадлежит текущему процессу
if [[ -f "$SCRIPT_LOCK" && $(< "$SCRIPT_LOCK") -eq $SCRIPT_PID ]]; then
rm -f "$SCRIPT_LOCK"
fi
}
# Функция логирования
logging() {
while IFS= read -r line; do
# Форматируем строку лога с временной меткой
log_line="$(date +"${SCRIPT_LOG_PREFIX}") - $line"
# Выводим лог в stdout, если включено
if (( "$LOG_TO_STDOUT" )); then echo "$log_line"; fi
# Логируем в файл, если включено
if (( "$LOG_TO_FILE" )); then echo "$log_line" >> "$SCRIPT_LOG"; fi
# Логируем в syslog, если включено
if (( "$LOG_TO_SYSLOG" )); then logger -t "$SCRIPT_NAME" -- "$line"; fi
done
}
# Функция блокировки скрипта для предотвращения параллельного запуска
lock_script() {
# Открываем дескриптор файла блокировки для записи
exec {fd_lock}>> "${SCRIPT_LOCK}"
# Пытаемся получить эксклюзивный lock, если не удалось — выходим
if ! flock -n "$fd_lock"; then
echo "Another script instance is already running, exiting..."
exit 1
fi
# Записываем PID текущего скрипта в файл блокировки
echo "$SCRIPT_PID" > "$SCRIPT_LOCK"
}
# Основная функция деплоя
deploy() {
echo "Check git and docker binaries"
# Проверка наличия git и docker
if ! command -v git; then echo >&2 "Git is not installed"; fi
if ! command -v docker; then echo >&2 "Docker is not installed"; fi
# Переходим в директорию проекта, если есть права на запись
if [[ -w "$PROJECT_DIR" ]]; then
cd "$PROJECT_DIR"
else
echo "No such directory: $PROJECT_DIR"
exit 1
fi
# Если есть git репозиторий — обновляем его
if [[ -d .git ]]; then
echo "Pull changes from main branch"
git checkout main
git fetch --all
git reset --hard origin/main
else
echo "No git files found"
exit 1
fi
echo "Restart $PROJECT_NAME service"
#systemctl restart "$PROJECT_NAME" # можно использовать systemctl
docker compose up hugo # запуск сервиса через Docker Compose
sleep 3 # небольшой таймаут
echo "Waiting for site generation..."
# Ждем, пока сервис Hugo завершит работу
while docker compose ps --services --filter "status=running" | grep -q "^$PROJECT_NAME$"; do
echo "Waiting for site generation..."
sleep 1
done
echo 'Done!'
# Проверяем, что веб-сервер запущен
echo "Checking if webserver container is running"
if docker compose ps --services --filter "status=running" | grep -q "^$PROJECT_WEB$"; then
echo 'Up!'
else
echo "Container with $PROJECT_WEB webserver is not running"
exit 1
fi
sleep 3
# Вывод последних логов сервиса
echo "Some output of running service"
LOG=$(journalctl -n 50 --no-pager -u "$PROJECT_NAME")
echo "------------------------------"
echo "$LOG"
echo "------------------------------"
}
# Главная функция скрипта
main() {
# Ловим сигналы и ошибки, вызываем cleanup
trap 'RC=$?; cleanup; exit $RC' SIGINT SIGHUP SIGTERM SIGQUIT ERR EXIT
# Перенаправляем stdout и stderr в функцию логирования
exec > >(logging) 2>&1
lock_script # блокируем скрипт
deploy # запускаем деплой
}
# Точка входа
if main; then
sleep 1
echo "Deploy completed successfully"
exit 0
else
echo "Deploy failed"
exit 1
fiСкрипт обеспечивает безопасное выполнение, логирование действий в stdout, файл или syslog, блокировку для предотвращения параллельного запуска, а также выполняет следующие шаги:
- проверяет наличие необходимых утилит (
gitиdocker); - извлекает последние изменения из репозитория
git; - перезапускает
docker composeсервисhugo; - ожидает завершения генерации сайта;
- проверяет работоспособность веб-сервера и выводит последние 50 строк журнала сервиса.
В случае успеха выводится сообщение об успешном завершении, в противном случае - об ошибке.
Весь вывод скрипта будет виден в разделе CI/CD на GitHub.
Делаем скрипт исполняемым:
chmod 700 ./deploy.shИ давайте наконец зафиксируем изменения и отправим их в удалённый репозиторий:
git add .
git commit -m 'Get test deploy!'
git push origin main
Переходим в веб GitHub, в раздел Actions:
Видим запущенный пайплайн:

В него можно перейти, чтобы посмотреть выполнение этапов CI/CD в реальном времени.
При успешном завершении будет так:

В выводе этапа deploy должно быть Pong!.

📝В любом случае в Телеграм прилетит уведомление со статусом выполненных (или нет) задач:
Это хороший знак, значит пайп отработал корректно. Осталось только притащить скрипт deploy.sh на сервер.
☝️Следующие действия выполняются на удалённом сервере hugo.r4ven.me от имени root пользователя.
В директории проекта на сервере пулим изменения, которые мы ранее отправили с клиента:
cd /opt/hugo
git fetch --all
git reset --hard origin/main
Проверяем, что на сервер прилетел актуальный скрипт:
less deploy.sh
Всё на месте. Теперь контрольный тест всего пайплайна + деплой скирпт.
☝️Следующие действия выполняются на локальной машине desktop.lan.
С клиентской машины добавляем новую markdown заметку:
cd ~/Hugo
cat << EOF > ./hugo_data/hugo.r4ven.me/content/posts/my-third-post.md
---
title: "My third Post"
date: 2025-08-20
draft: false
categories: ["Test"]
tags: ["Hugo", "Guide"]
---
## Hello, R4ven!
EOFИ пушим изменения в репозитория:
git add . && git commit -m 'Get real deploy!' && git push
Смотрим раздел actions в вебе GitHub:

Отлично! Пайп отработал. На этапе “Deploy to remote server” будет вывод нашего скрипта:
Теперь проверяем обновился ли наш сайт в браузере:

Таки да! Появилась третья заметка:

Если вы успешно дошли до этого момент, поздравляю! Вы уже сделали самое сложное.
Осталось подумать об удобстве деплоя нового контента и настройки сайта…
Настройка Obsidian
💡К слову, что такое Obsidian и зачем он вам нужен: 🔗 Obsidian — Прогрессивный инструмент для ведения заметок на ПК и смартфоне
Запускаем Obsidian, открываем папку как хранилище (vault):

И выбираем ~/Hugo/hugo.r4ven.me/content/:

Видим наши посты. Теперь переходим в настройки программы:
Базовая настройка для Markdown
Раздел Редактор:
| Key | Value |
|---|---|
Двойной разделитель строк | включаем |
Использовать табуляцию | отключаем |
Раздел Файлы и ссылки:
| Key | Value |
|---|---|
Место для новых заметок по умолчанию | В папке, указанной ниже |
Папка в которой создавать новые заметки | posts |
Формат новой ссылки | Относительный путь к файлу |
Use [[Wikilinks]] | отключаем |
Место для вложенных файлов по умолчанию | В подпапке в текущей папке |
Имя подпапки | attachments |
Установка Git плагина
Переходим в “Настройки” –> “Сторонние плагины” –> “Обзор”
Устанавливаем и включаем плагин Git:

Теперь по кнопке слева можно вызвать панель справа🤷♂️.

В которой будут показаны изменения, отслеживаемые Git’ом:

Нам не пришлось настраивать репозиторий в плагине Git потому, что директория ~/Hugo ранее уже была нами инициализирована.
Добавляйте заметки, и нажимайте кнопку “Commit-and-sync” на панели справа:

Об успехе отправки в удаленный репозиторий будет говорить уведомление:

Установка Templater плагина (опционально)
Моей рекомендацией вам будет плагин Templater. С его помощью можно писать скрипты автоматизации действий с заметками внутри Obsidian.
Давайте покажу, о чём речь. Сперва устанавливаем Templater:

Переходим в настройки плагина и указываем в параметре “Template folder location” значение templates:

Теперь в корне Obsidian создаём папку templates, а в ней файл с названием init. В который вставляем следующее содержимое:
<%*
function translit(str) {
const map = {
'а':'a','б':'b','в':'v','г':'g','д':'d','е':'e','ё':'yo','ж':'zh',
'з':'z','и':'i','й':'y','к':'k','л':'l','м':'m','н':'n','о':'o',
'п':'p','р':'r','с':'s','т':'t','у':'u','ф':'f','х':'h','ц':'c',
'ч':'ch','ш':'sh','щ':'shch','ъ':'','ы':'y','ь':'','э':'e','ю':'yu','я':'ya'
};
return str.toLowerCase()
.trim()
.replace(/\s+/g,
'-') // пробелы → дефисы
.split('')
.map(c => map[c] ?? c)
.join('')
.replace(/[^a-z0-9\-_.]/g, '')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '');
}
function getAllFolders() {
const out = [];
const root = app.vault.getRoot();
function walk(folder) {
out.push(folder.path);
if (!folder.children) return;
folder.children.forEach(c => {
if (Array.isArray(c.children)) walk(c);
});
}
walk(root);
return out;
}
try {
const folders = getAllFolders();
if (!folders || folders.length === 0) {
new Notice("Не удалось получить список папок в хранилище.");
return;
}
// выбор базовой папки (например content/posts/linux)
const basePath = await tp.system.suggester(folders, folders);
if (!basePath) {
new Notice("Выбор папки отменён.");
return;
}
const today = tp.date.now("YYYY-MM-DD");
const rusName = await tp.system.prompt("Введите название (на русском):");
if (!rusName) {
new Notice("Название не введено — отменено.");
return;
}
const slug = translit(rusName) || "untitled";
const folderName = `${today}-${slug}`;
const finalPath = `${basePath}/${folderName}`;
// создаём новую папку для поста
await app.vault.createFolder(finalPath).catch(()=>{});
// категория — последний элемент пути basePath
const categoryName = basePath.split("/").pop();
// фронтматтер
const frontmatter =
`---
draft: true
title: ${JSON.stringify(rusName)}
date: ${today}
lastmod:
author: Иван Чёрный
toc: false
slug: ${slug}
url: /${categoryName}/${slug}
aliases:
categories:
- ${categoryName}
tags:
- raven
cover: cover.jpg
description: Описание поста.
---
## Приветствие
## Содержание статьи
## Заключение
`;
// создаём index.md
const targetPath = `${finalPath}/index.md`;
const existing = app.vault.getAbstractFileByPath(targetPath);
if (existing) {
new Notice(`⚠️ Уже существует: ${targetPath}`);
} else {
await app.vault.create(targetPath, frontmatter);
new Notice(`✅ Создано: ${targetPath}`);
}
} catch (err) {
new Notice("Ошибка скрипта: " + (err?.message ?? String(err)));
console.error(err);
}
%>
Пробуем использовать шаблон: нажимаем Alt+e (или кнопку плагина слева) и выбираем init:

Шаблон спросит нас в какой директории нужно создать новую заметку:

После чего нужно указать название заметки, желательно такое, которое будет подходящим заголовком для вашей новой статьи на сайте:

И Enter. В результате в папке posts создастся отдельная папка для заметки (дата + имя транслитом) и файл index.md, с уже заполненным front-matter и телом статьи.
💡Вы можете с легкостью скорректировать шаблон init под свои нужды.
Удобно? Как по мне очень!👍
Перед публикацией обязательно в front-matter убираем “флаг” черновика - draft.
Добавляем в заметку пару картинок простым Ctrl+c, Ctrl+v и небольшой блок кода для разнообразия:

Также рядом с файлом заметки index.md положим обложку для статьи: cover.png (указывается в front-matter). Важно, чтобы имя совпадало.
💡Подробнее о том, как тема Narrow обрабатывает пути к изображениям смотрите тут.
Ну и давайте это все добро опубликуем.
Тест процесса публикации контента
Нажимаем Commit-and-sync:

Идём в веб GitHub для проверки:

💡 Совет
Шаблон имени комитов можно задать в настройках плагина Git.
Деплой завершён🏁:

Уведомление в Telegeram пришло📬:

Статья на сайте появилась🌐:

Круть!🎉
Заключение
Фух..😔 Это было длительное и увлекательное путешествие! В итоге мы получили быстрый и безопасный статический сайт на Hugo, а главное - настроили комфортный процесс публикации: пишем заметки в Obsidian, нажимаем “одну кнопку”, и через несколько минут контент автоматически обновляется на сервере. Это, как сейчас говорится, и есть современный подход к ведению блога в формате “Docs as code” - эффективно, автоматизированно и приятно😎.
Спасибо, что читаете!
P.S. Рекомендую поближе познакомиться с Obsidian - это тот ещё комбайн. Скажу по опыту, он очень сильно упрощает процесс написания и организации статей/заметок.


