В первой части мы запустили Hugo локально. Сайт работает пока открыт терминал. Закрыл терминал - сайт умер.
Пора переносить это в K3s.
Архитектура деплоя#
Git Push
↓
Gitea (внутренний)
↓ webhook POST
Hugo Builder
├→ git clone + submodule
├→ hugo --minify
└→ output → NFS
↓
/export/blog-public/
↓
Nginx (x2 реплики)
↓
Traefik Ingress
↓
your-blog.ru (SSL)Пять компонентов:
- NFS - хранилище для статики (OpenMediaVault)
- Hugo Builder - пересобирает сайт при каждом пуше
- Nginx - раздаёт статику с NFS
- cert-manager - автоматический SSL от Let’s Encrypt
- Traefik IngressRoute - маршрутизация с SSL терминацией
Шаг 1: NFS хранилище#
Hugo собирает статику в HTML/CSS/JS файлы. Nginx раздаёт эти файлы. Значит нужно общее хранилище куда Hugo пишет, а Nginx читает.
NFS - самый простой вариант для homelab. У меня OpenMediaVault на отдельной машине.
Создаём директории на NAS#
# Подключаемся к NAS (SSH на нестандартном порту для безопасности)
ssh -p 33322 nasadmin@192.168.11.30
# Создаём папки для production и development окружений
sudo mkdir -p /srv/storage/blog/blog-public
sudo mkdir -p /srv/storage/blog/blog-public-dev
# Выдаём права на запись (контейнеры пишут от root)
sudo chmod -R 775 /srv/storage/blog/Почему SSH на порту 33322? Стандартный порт 22 - первая цель сканеров и ботов. Нестандартный порт снижает шум в логах и количество brute-force попыток до нуля. Безопасность через скрытность работает для домашних серверов.
Настраиваем NFS через OMV Web UI#
Storage → Shared Folders → Create:
- Name:
blog-public - Device: основной диск
- Path:
/blog/blog-public
Services → NFS → Shares → Create:
- Shared folder:
blog-public - Client:
192.168.11.0/24 - Privilege: Read/Write
- Extra options:
rw,sync,no_subtree_check,no_root_squash
То же для blog-public-dev.
Критично: no_root_squash - без этого контейнеры не смогут записывать файлы (они пишут от root внутри контейнера).
Проверяем экспорт#
# Заходим на NAS
ssh -p 33322 nasadmin@192.168.11.30
# Проверяем что NFS экспортирует наши шары
sudo exportfs -v | grep blog
# Ожидаемый вывод - две строки с настройками экспорта:
# /export/blog-public 192.168.11.0/24(rw,sync,no_root_squash,...)
# /export/blog-public-dev 192.168.11.0/24(rw,sync,no_root_squash,...)Шаг 2: PersistentVolumes в K3s#
K3s нужно сказать где лежат NFS шары. Создаём манифест с PersistentVolume ресурсами.
Файл: 02-pv.yaml
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: blog-public-pv
spec:
capacity:
storage: 5Gi
accessModes:
- ReadWriteMany
nfs:
server: 192.168.11.30 # IP вашего NAS
path: /export/blog-public
mountOptions:
- nfsvers=3
- hard
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: blog-public-dev-pv
spec:
capacity:
storage: 5Gi
accessModes:
- ReadWriteMany
nfs:
server: 192.168.11.30
path: /export/blog-public-dev
mountOptions:
- nfsvers=3
- hardПочему NFSv3, а не NFSv4? Потому что NFSv4.2 в K3s не работал - поды виснут в ContainerCreating с ошибкой mount.nfs: No such file or directory. NFSv3 работает стабильно. Не надо усложнять то что работает.
# Применяем манифест
kubectl apply -f 02-pv.yaml
# Проверяем что PV создались и привязались
kubectl get pv | grep blog
# blog-public-pv 5Gi RWX Bound blog/blog-public-pvcШаг 3: Hugo Builder#
Нужен контейнер который слушает webhook от Gitea, клонирует репозиторий и собирает Hugo.
Зачем нужен Hugo Builder?#
Проблема: Hugo генерирует статику командой hugo. Где её запускать? На локальной машине? Тогда нужно вручную заливать файлы на сервер после каждого изменения. Неудобно и ломает автоматизацию.
Решение: Контейнер который живёт в K3s, слушает webhook от Gitea и автоматически пересобирает сайт при каждом git push.
Dockerfile#
FROM alpine:3.19
# Устанавливаем всё что нужно Hugo и Git
RUN apk add --no-cache \
git nodejs npm bash curl wget \
libc6-compat libstdc++ ca-certificates
# Скачиваем Hugo Extended v0.155.3
WORKDIR /tmp
RUN wget https://github.com/gohugoio/hugo/releases/download/v0.155.3/hugo_extended_0.155.3_linux-amd64.tar.gz && \
tar -xzf hugo_extended_0.155.3_linux-amd64.tar.gz && \
cp hugo /usr/bin/hugo && \
chmod +x /usr/bin/hugo && \
rm -rf /tmp/*
WORKDIR /workspace
# Копируем скрипты
COPY webhook-listener.sh /usr/local/bin/
COPY build.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/*.sh
EXPOSE 8080
CMD ["/usr/local/bin/webhook-listener.sh"]build.sh - скрипт сборки Hugo#
Зачем: Отдельный скрипт сборки нужен чтобы его можно было запускать не только из webhook listener, но и вручную для тестирования. Один скрипт - одна ответственность.
#!/bin/bash
set -e # Остановиться при первой ошибке
export GIT_TERMINAL_PROMPT=0 # Не запрашивать пароли интерактивно
REPO_URL="https://git.example.com/user/blog.git" # URL вашего Gitea репозитория
BRANCH="${BRANCH:-main}" # Ветка (передаётся через env)
OUTPUT_DIR="/mnt/blog-public" # Куда складывать собранную статику (NFS)
WORK_DIR="/tmp/build" # Временная папка для клонирования
# Чистим рабочую директорию от прошлой сборки
rm -rf ${WORK_DIR}
mkdir -p ${WORK_DIR}
# Клонируем репозиторий (только нужную ветку, без истории)
cd ${WORK_DIR}
git clone --branch ${BRANCH} --depth 1 ${REPO_URL} site 2>&1
cd site
# Подтягиваем тему Blowfish как Git submodule
git submodule update --init --recursive --depth 1 2>&1
# Собираем сайт (минифицируем CSS/JS/HTML)
hugo --minify --destination ${OUTPUT_DIR} 2>&1
# Проверяем что сборка прошла успешно
if [ -f "${OUTPUT_DIR}/index.html" ]; then
echo "Build successful!"
else
echo "Build failed - index.html not found"
exit 1
fi
# Убираем за собой
rm -rf ${WORK_DIR}webhook-listener.sh - слушатель webhook#
Зачем: Gitea отправляет HTTP POST запрос при каждом git push. Нужен простой HTTP сервер который принимает этот запрос и запускает сборку. netcat - самый простой способ поднять HTTP listener без зависимостей.
#!/bin/bash
set -e
echo "Starting webhook listener on port 8080..."
while true; do
# Принимаем HTTP запрос через netcat и сразу отвечаем 200 OK
echo -e "HTTP/1.1 200 OK\r\n\r\nWebhook received" | nc -l -p 8080
# Запускаем сборку синхронно (чтобы видеть логи в kubectl logs)
echo "$(date): Webhook triggered, starting build..."
/usr/local/bin/build.sh
echo "$(date): Build completed, waiting for next webhook..."
doneСборка и деплой образа#
# Собираем Docker образ
docker build -t hugo-builder:latest .
# Сохраняем в tar файл
docker save hugo-builder:latest -o /tmp/hugo-builder.tar
# Копируем на все K3s worker ноды
for ip in 210 211; do
scp /tmp/hugo-builder.tar k3s@192.168.11.$ip:/tmp/
# Импортируем образ в containerd K3s
ssh k3s@192.168.11.$ip "sudo k3s ctr images import /tmp/hugo-builder.tar && rm /tmp/hugo-builder.tar"
doneDeployment и Service#
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: hugo-builder-prod
namespace: blog
spec:
replicas: 1
selector:
matchLabels:
app: hugo-builder-prod
template:
metadata:
labels:
app: hugo-builder-prod
spec:
containers:
- name: hugo-builder
image: hugo-builder:latest
imagePullPolicy: Never # Образ локальный, не тянуть из registry
env:
- name: BRANCH
value: "main" # Для prod используем main ветку
volumeMounts:
- name: public
mountPath: /mnt/blog-public # NFS хранилище
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
volumes:
- name: public
persistentVolumeClaim:
claimName: blog-public-pvc
---
apiVersion: v1
kind: Service
metadata:
name: hugo-builder-prod
namespace: blog
spec:
selector:
app: hugo-builder-prod
ports:
- port: 8080
targetPort: 8080
name: webhook# Применяем манифест
kubectl apply -f 01-hugo-builder-prod.yaml
# Проверяем что под запустился
kubectl get pods -n blog | grep hugo-builder
# Смотрим логи - должна быть строка "Starting webhook listener"
kubectl logs -n blog deployment/hugo-builder-prodШаг 4: Nginx с Prometheus exporter#
Nginx раздаёт статику с того же NFS где Hugo её собрал. Две реплики для минимальной доступности при обновлениях.
Бонус: sidecar контейнер с nginx-prometheus-exporter для мониторинга через Grafana.
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
namespace: blog
spec:
replicas: 2 # Две реплики для доступности
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
# Основной контейнер - Nginx
- name: nginx
image: nginx:1.25-alpine
ports:
- containerPort: 80
volumeMounts:
- name: html
mountPath: /usr/share/nginx/html
readOnly: true # Nginx только читает, не пишет
- name: config
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
resources:
requests:
cpu: 50m
memory: 64Mi
# Sidecar - экспортер метрик для Prometheus
- name: nginx-exporter
image: nginx/nginx-prometheus-exporter:1.1.0
args:
- -nginx.scrape-uri=http://localhost/nginx_status
ports:
- containerPort: 9113
name: metrics
resources:
requests:
cpu: 10m
memory: 16Mi
volumes:
- name: html
persistentVolumeClaim:
claimName: blog-public-pvc # NFS хранилище
- name: config
configMap:
name: nginx-config
---
apiVersion: v1
kind: Service
metadata:
name: nginx
namespace: blog
spec:
selector:
app: nginx
ports:
- port: 80
targetPort: 80
name: http
- port: 9113
targetPort: 9113
name: metrics # Для PrometheusШаг 5: SSL сертификаты#
cert-manager автоматически получает сертификаты от Let’s Encrypt через HTTP-01 challenge.
Важно: Сначала настрой A-запись у DNS провайдера:
your-blog.ru A 77.37.XXX.XXX (ваш внешний IP)
www.your-blog.ru A 77.37.XXX.XXXБез этого Let’s Encrypt не сможет проверить что домен принадлежит вам.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: blog-tls
namespace: blog
spec:
secretName: blog-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- your-blog.ru
- www.your-blog.ru# Применяем манифест
kubectl apply -f 04-certificate.yaml
# Ждём 30-60 секунд пока cert-manager получит сертификат
kubectl get certificate -n blog
# Должно быть READY=True
# NAME READY SECRET AGE
# blog-tls True blog-tls 45sШаг 6: IngressRoute через Traefik#
Traefik маршрутизирует трафик на Nginx и делает SSL терминацию.
---
# HTTP → HTTPS редирект (опционально)
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: blog-http
namespace: blog
spec:
entryPoints:
- web # Порт 80
routes:
- match: Host(`your-blog.ru`) || Host(`www.your-blog.ru`)
kind: Rule
services:
- name: nginx
port: 80
---
# HTTPS с SSL
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: blog-https
namespace: blog
spec:
entryPoints:
- websecure # Порт 443
routes:
- match: Host(`your-blog.ru`) || Host(`www.your-blog.ru`)
kind: Rule
services:
- name: nginx
port: 80
tls:
secretName: blog-tls # Сертификат от cert-manager# Применяем манифест
kubectl apply -f 05-ingressroute.yaml
# Проверяем что сайт доступен
curl -I https://your-blog.ru
# HTTP/2 200Шаг 7: Webhook в Gitea#
Последний шаг - связать Gitea с Hugo Builder.
Gitea → ваш репозиторий → Settings → Webhooks → Add Webhook → Gitea
- URL:
http://hugo-builder-prod.blog.svc.cluster.local:8080 - HTTP Method: POST
- Content Type: application/json
- Trigger On: Push events
- Branch filter:
main
Нажимаем “Test Delivery” - должен вернуть 200 OK.
Проверяем логи Hugo Builder:
# Следим за логами в реальном времени
kubectl logs -n blog deployment/hugo-builder-prod -f
# Должно появиться:
# Webhook triggered, starting build...
# Cloning repository...
# Initializing submodules...
# Building Hugo site...
# Build successful!Проверка работы#
# Меняем статью
cd ~/hugo-projects/blog
git checkout main
echo "## Тестовая правка" >> content/posts/hello-world/index.md
# Коммитим и пушим
git add .
git commit -m "test: проверка автосборки"
git push origin main
# Следим за логами Hugo Builder
kubectl logs -n blog deployment/hugo-builder-prod -f
# Через 5-7 секунд сборка завершится
# Проверяем что изменение попало на сайт
curl -s https://your-blog.ru/posts/hello-world/ | grep "Тестовая правка"Если видите “Тестовая правка” - всё работает. Каждый git push автоматически обновляет сайт.
Что дальше#
Production окружение развёрнуто. Но пока только для ветки main.
В следующей части добавим development окружение с отдельным Hugo Builder, Nginx и защитой через Basic Auth. Два независимых пайплайна в одном namespace.
Стек этой части:
- K3s 1.30
- NFS на OpenMediaVault
- Hugo Builder (Alpine + Hugo v0.155.3)
- Nginx 1.25 + Prometheus exporter
- cert-manager + Let’s Encrypt
- Traefik IngressRoute






