Поднимаем свой сайт на Hugo с публикацией контента через Obsidian
Приветствую!

В этой инструкции я расскажу, как поднять свой сайт с использованием генератора статических сайтов Hugo. А также покажу процесс создания контента с помощью Obsidian и его автоматической публикации через CI/CD👨‍💻.

Предисловие: зачем это всё?

Многие пользователи 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 гурманов😇. И я обязательно постараюсь вам помочь в свободное время.

Схема работы

Теперь коротко про то, что мы сегодня настроим и как это будет работать. Схема работы будущей системы публикации контента следующая:

  1. Редактирование .md файлов;
  2. commit и push изменений в удаленный репозиторий;
  3. Запуск процесса CI/CD (GitHub Actions);
  4. Build: сборка файлов сайта и их валидация;
  5. Deploy: выполнение SSH команды (запуск скрипта) на сервере, где запущен сайт;
  6. Notify: проверка статуса и отправка уведомления в Telegram об успехе/провале деплоя.

Ниже представлена упрощенная визуализация описанной выше схемы:

Предварительные требования

Для реализации нашего плана нам понадобятся:

Вводные данные

Ниже вводные данные, которые будут использоваться в статье:

KeyValue
Имя сервераhugo.r4ven.me
SSH порт сервера2222
ОС сервераDebian 13
Имя пользователяivan
Имя локальной машиныdesktop.lan
Docker образ Hugodebian-0.151.0
Docker образ Angie1.10.2-alpine

Рекомендую сразу открыть два терминала или две вкладки: в первой мы подключимся к удаленному серверу, hugo.r4ven.me в моём примере, а во второй наша локальная машина (desktop.lan в моём примере).

Подключаемся к нашему серверу по SSH и переходим в привилегированный режим root, например, через sudo:

BASH
ssh -p 2222 ivan@hugo.r4ven.me

sudo -s
Нажмите, чтобы развернуть и увидеть больше

Запуск Angie и Hugo

Создаём нужную структуру директорий в /opt и переходим в рабочую:

BASH
mkdir -vp /opt/hugo/{hugo,angie}_data && cd /opt/hugo/
Нажмите, чтобы развернуть и увидеть больше

Теперь создаём compose файл для сервисов Angie и Hugo:

BASH
nvim docker-compose.yml
Нажмите, чтобы развернуть и увидеть больше

Наполняем:

YAML
---

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"]
Нажмите, чтобы развернуть и увидеть больше

Настройка и запуск Angie

Далее создадим файл конфигурации веб-сервера angie.conf:

BASH
nvim ./angie_data/angie.conf
Нажмите, чтобы развернуть и увидеть больше
BASH
user 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;
}
Нажмите, чтобы развернуть и увидеть больше

Замените следующие параметры в конфиге на свои:

BASH
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 и просмотру логов:

BASH
docker compose up -d angie

docker compose ps

docker compose logs -f
Нажмите, чтобы развернуть и увидеть больше

Docker скачает нужный образ и запустит контейнер. При первом запуске angie запросит TLS сертификаты у lets encrypt. В выводе контейнера вы должны увидеть подобное:

Значит все Ok.

Проверяем физическое наличие сертификатов:

BASH
ls -lR ./angie_data/certs/
Нажмите, чтобы развернуть и увидеть больше

Если всё запустилось успешно, проверяем работу веб-сервера и смотрим данные TLS сертификата:

BASH
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 на свой):

BASH
docker compose run --rm -w /src hugo new site hugo.r4ven.me
Нажмите, чтобы развернуть и увидеть больше

Клонируем репозиторий темы Narrow:

BASH
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/
Нажмите, чтобы развернуть и увидеть больше

Подчищаем лишнее:

BASH
rm -f ./hugo_data/hugo.r4ven.me/hugo.toml

rm -rf ./hugo_data/hugo.r4ven.me/themes/hugo-narrow/.git
Нажмите, чтобы развернуть и увидеть больше

И указываем свой адрес в конфиг файле Hugo:

BASH
sed -i "s|^baseURL.*|baseURL: 'https://hugo.r4ven.me'|" \
    ./hugo_data/hugo.r4ven.me/hugo.yaml
Нажмите, чтобы развернуть и увидеть больше

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

Заметка 1
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!
EOF
Нажмите, чтобы развернуть и увидеть больше
Заметка 2
cat << 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
Нажмите, чтобы развернуть и увидеть больше

Проверяем:

BASH
ls -l ./hugo_data/hugo.r4ven.me/content/posts/
Нажмите, чтобы развернуть и увидеть больше

Как вы обратили внимания, вначале каждой заметки есть некоторые метаданные, так называемый Front matter.

Теперь запустим обновление/генерацию нашего сайта:

BASH
docker compose up hugo
Нажмите, чтобы развернуть и увидеть больше

Команда выполнит генерацию и, если все ок, покажет итоговый статус файлов сайта:

Проверяем работу сайта в консоли:

BASH
curl https://hugo.r4ven.me/posts/first-post/

curl https://hugo.r4ven.me/posts/second-post/
Нажмите, чтобы развернуть и увидеть больше

И конечно же в браузере https://hugo.r4ven.me:

Site is up!

Опционально: настройка запуска Hugo с помощью Systemd

Для удобного управления всем стеком Compose я обычно использую Systemd.

Создаём Systemd unit сервис:

BASH
systemctl edit --full --force hugo.service
Нажмите, чтобы развернуть и увидеть больше

Наполняем примерно таким содержимым:

INI
[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:

BASH
docker compose down

systemctl enable --now hugo
Нажмите, чтобы развернуть и увидеть больше

Проверяем статус контейнера/сервиса:

BASH
docker compose ps

systemctl status hugo

journalctl -fu hugo
Нажмите, чтобы развернуть и увидеть больше

Теперь можно выполнять systemctl start|stop|restart hugo для управления.

Настройка CI/CD на примере GitHub Actions

Чтож. Сайт работает, но обновлять/наполнять его контентом через консоль - задача для энтузиастов. У нас таких нет, поэтому приступаем к настройке автоматики.

Прежде всего создадим SSH ключи, которые будут использоваться для доступа к репозиторию во время деплоя:

BASH
mkdir -vp ~/.ssh/ && chmod 700 ~/.ssh/

ssh-keygen -q -N "" -t ed25519 -f ~/.ssh/id_ed25519_github_repo

ls -l ~/.ssh/
Нажмите, чтобы развернуть и увидеть больше

Сразу же укажем SSH использовать данный ключ при обращении к Github:

BASH
cat << EOF >> ~/.ssh/config
Host github.com
    HostName github.com
    User git
    IdentityFile ~/.ssh/id_ed25519_github_repo
    IdentitiesOnly yes
EOF
Нажмите, чтобы развернуть и увидеть больше

И прежде, чем мы перейдём к следующему шагу, выведем и скопируем наш публичный SSH ключ:

BASH
cat ~/.ssh/id_ed25519_github_repo.pub
Нажмите, чтобы развернуть и увидеть больше

Мы добавим его в качестве “deploy key” для доступа к репозиторию.

Переходим на github, входим в свой аккаунт (или создаём новый). Далее создаём новый репозиторий:

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

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

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

Также указываем имя, сам публичный SSH ключ в поле “Key” и включаем “Allow write access”:

Теперь возвращаемся в консоль нашего сервера и настраиваем git.

Устанавливаем если его еще нет и переходим в директорию сайта:

BASH
apt update && apt install -y git

cd /opt/hugo/
Нажмите, чтобы развернуть и увидеть больше

Инициализируем новый репозиторий, настраиваем исключения:

BASH
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), добавляем файлы в индекс и фиксируем изменения (комитим):

BASH
find ./hugo_data/hugo.r4ven.me/ -type d -empty -exec touch {}/.gitkeep \;

git add ./

git commit -m "Hugo initial commit"
Нажмите, чтобы развернуть и увидеть больше

В конце синхронизируем локальный репозиторий с удаленным:

BASH
git branch -M main

git remote add origin git@github.com:r4ven-me/hugo.git

git push -u origin main
Нажмите, чтобы развернуть и увидеть больше

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

Всё хорошо. Продолжаем настройку.

Теперь выполняем настройку на клиентской машине с которой планируется заниматься наполнением сайта.

Создаём папку проекта и клонируем наш удалённый репозиторий:

BASH
git clone git@github.com:r4ven-me/hugo.git ~/Hugo

mkdir -p ~/Hugo/.github/workflows

cd ~/Hugo
Нажмите, чтобы развернуть и увидеть больше

В этой директории создаём специальный файл описания CI/CD пайплайна, который автоматически считывается самим GitHub при пуше в репо:

BASH
nvim .github/workflows/deploy.yml
Нажмите, чтобы развернуть и увидеть больше
YAML
---

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 }}
Нажмите, чтобы развернуть и увидеть больше

Обязательно меняем имя домена на своё:

BASH
sed -i 's|hugo.r4ven.me|example.com|g' .github/workflows/deploy.yml
Нажмите, чтобы развернуть и увидеть больше

Теперь необходимо создать реквизиты доступа к нашему удалённому серверу по SSH для этапа deploy из пайплайна.

На клиентской машине генерируем новый ключ SSH и выводим в терминал его ПРИВАТНУЮ часть:

BASH
ssh-keygen -q -N "" -t ed25519 -f ~/.ssh/id_ed25519_github_cicd

cat ~/.ssh/id_ed25519_github_cicd
Нажмите, чтобы развернуть и увидеть больше
id_ed25519_github_cicd
-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----
Нажмите, чтобы развернуть и увидеть больше

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

KeyValue
USERivan
HOSThugo.r4ven.me
PORT2222
KEY—–BEGIN OPENSSH PRIVATE KEY—–

—–END OPENSSH PRIVATE KEY—–
TELEGRAM_ID12345678
TELEGRAM_TOKEN123456qwerty

Под спойлером короткая инструкция, как получить токена бота (TELEGRAM_TOKEN) и id чата (TELEGRAM_ID):

Вот такие секреты у вас должны быть:

Теперь выводим в терминале содержимое ПУБЛИЧНОГО ключа:

BASH
cat ~/.ssh/id_ed25519_github_cicd.pub
Нажмите, чтобы развернуть и увидеть больше
id_ed25519_github_cicd.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGvDu1omD1WwG1K68/mkvnBw/d4xRpREHDH4ytMDOl20
Нажмите, чтобы развернуть и увидеть больше

И переходим к настройке запуска скрипта при подключении на этапе deploy в пайплайне.

Теперь на сервере создаём специально настроенный authorized_keys для SSH:

BASH
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 укажите свой публичный ключ, который вывели в терминале на клиентской машине шагом ранее.

Теперь давайте настроим сервер, чтобы команда sudo /opt/hugo/deploy.sh могла успешно выполняться.

Создаём sudoers файл:

BASH
visudo -f /etc/sudoers.d/90_hugo
Нажмите, чтобы развернуть и увидеть больше

Наполняем:

PLAINTEXT
ivan ALL=(root) NOPASSWD: /opt/hugo/deploy.sh
Нажмите, чтобы развернуть и увидеть больше

Теперь создадим на сервере тестовый deploy.sh:

BASH
echo -e '#!/usr/bin/bash\necho Pong' > /opt/hugo/deploy.sh

chmod 700 /opt/hugo/deploy.sh
Нажмите, чтобы развернуть и увидеть больше

Давайте протестируем его работу с клиента, на котором мы сгерерировали CI/CD ключ: id_ed25519_github_cicd.

Пробуем подключиться к серверу:

BASH
ssh -p 2222 -i ~/.ssh/id_ed25519_github_cicd ivan@hugo.r4ven.me
Нажмите, чтобы развернуть и увидеть больше

В ответ вы должны получить Pong из нашего deploy.sh:

PLAINTEXT
PTY allocation request failed on channel 0
Pong
Connection to hugo.r4ven.me closed.
Нажмите, чтобы развернуть и увидеть больше

Если это так, то мы все настроили корректно.

Теперь давайте напишем полноценный Bash скрипт для деплоя Hugo в директории проекта ~/Hugo:

BASH
nvim ./deploy.sh
Нажмите, чтобы развернуть и увидеть больше

На полняем:

BASH
#!/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, блокировку для предотвращения параллельного запуска, а также выполняет следующие шаги:

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

Весь вывод скрипта будет виден в разделе CI/CD на GitHub.

Делаем скрипт исполняемым:

BASH
chmod 700 ./deploy.sh
Нажмите, чтобы развернуть и увидеть больше

И давайте наконец зафиксируем изменения и отправим их в удалённый репозиторий:

BASH
git add .

git commit -m 'Get test deploy!'

git push origin main
Нажмите, чтобы развернуть и увидеть больше

Переходим в веб GitHub, в раздел Actions:

Видим запущенный пайплайн:

В него можно перейти, чтобы посмотреть выполнение этапов CI/CD в реальном времени.

При успешном завершении будет так:

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

Это хороший знак, значит пайп отработал корректно. Осталось только притащить скрипт deploy.sh на сервер.

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

BASH
cd /opt/hugo

git fetch --all

git reset --hard origin/main
Нажмите, чтобы развернуть и увидеть больше

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

BASH
less deploy.sh
Нажмите, чтобы развернуть и увидеть больше

Всё на месте. Теперь контрольный тест всего пайплайна + деплой скирпт.

С клиентской машины добавляем новую markdown заметку:

BASH
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
Нажмите, чтобы развернуть и увидеть больше

И пушим изменения в репозитория:

BASH
git add . && git commit -m 'Get real deploy!' && git push
Нажмите, чтобы развернуть и увидеть больше

Смотрим раздел actions в вебе GitHub:

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

Теперь проверяем обновился ли наш сайт в браузере:

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

Если вы успешно дошли до этого момент, поздравляю! Вы уже сделали самое сложное.

Осталось подумать об удобстве деплоя нового контента и настройки сайта…

Настройка Obsidian

Запускаем Obsidian, открываем папку как хранилище (vault):

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

Видим наши посты. Теперь переходим в настройки программы:

Базовая настройка для Markdown

Раздел Редактор:

KeyValue
Двойной разделитель строквключаем
Использовать табуляциюотключаем

Раздел Файлы и ссылки:

KeyValue
Место для новых заметок по умолчаниюВ папке, указанной ниже
Папка в которой создавать новые заметкиposts
Формат новой ссылкиОтносительный путь к файлу
Use [[Wikilinks]]отключаем
Место для вложенных файлов по умолчаниюВ подпапке в текущей папке
Имя подпапкиattachments

Установка Git плагина

Переходим в “Настройки” –> “Сторонние плагины” –> “Обзор”

Устанавливаем и включаем плагин Git:

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

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

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

Добавляйте заметки, и нажимайте кнопку “Commit-and-sync” на панели справа:

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

Установка Templater плагина (опционально)

Моей рекомендацией вам будет плагин Templater. С его помощью можно писать скрипты автоматизации действий с заметками внутри Obsidian.

Давайте покажу, о чём речь. Сперва устанавливаем Templater:

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

Теперь в корне Obsidian создаём папку templates, а в ней файл с названием init. В который вставляем следующее содержимое:

PLAINTEXT
<%*
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 и телом статьи.

Удобно? Как по мне очень!👍

Перед публикацией обязательно в front-matter убираем “флаг” черновика - draft.

Добавляем в заметку пару картинок простым Ctrl+c, Ctrl+v и небольшой блок кода для разнообразия:

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

Ну и давайте это все добро опубликуем.

Тест процесса публикации контента

Нажимаем Commit-and-sync:

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

Деплой завершён🏁:

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

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

Круть!🎉

Заключение

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

Спасибо, что читаете!

P.S. Рекомендую поближе познакомиться с Obsidian - это тот ещё комбайн. Скажу по опыту, он очень сильно упрощает процесс написания и организации статей/заметок.

Используемые материалы

Авторские права

Автор: Иван Чёрный

Ссылка: https://r4ven.me/web/podnimaem-svoy-sayt-na-hugo-s-publikaciey-kontenta-cherez-obsidian/

Лицензия: CC BY-NC-SA 4.0

Использование материалов блога разрешается при условии: указания авторства/источника, некоммерческого использования и сохранения лицензии.

Начать поиск

Введите ключевые слова для поиска статей

↑↓
ESC
⌘K Горячая клавиша