blog: Zola site, nginx image, Forgejo deploy pipeline
All checks were successful
Build and Deploy to Production / check (push) Successful in 9s
Build and Deploy to Production / build (push) Successful in 25s
Build and Deploy to Production / deploy (push) Successful in 36s

This commit is contained in:
Sergei Poljanski 2026-07-16 19:51:31 +04:00
commit a4b677f898
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
23 changed files with 561 additions and 0 deletions

View file

@ -0,0 +1,104 @@
name: Build and Deploy to Production
on:
push:
branches:
- main
jobs:
# Same job as in test.yaml (non-main branches); duplicated so a broken
# build blocks deploy — Forgejo has no cross-workflow needs.
check:
runs-on: nix-latest
steps:
- name: Prepare container for actions
run: |
echo "experimental-features = nix-command flakes" >> /etc/nix/nix.conf
nix-env -iA nixpkgs.nodejs_22
- name: Checkout
uses: actions/checkout@v4
- name: Build site
run: nix develop -c zola build
build:
runs-on: arch-latest
needs: check
container:
image: gcr.io/kaniko-project/executor:debug
outputs:
image_tag: ${{ steps.build_image.outputs.image_tag }}
steps:
- name: Build and push Docker Image using Kaniko
id: build_image
run: |
# Tags
SHORT_SHA=${GITHUB_SHA::8}
IMAGE_BASE=${{ secrets.FORGEJO_REGISTRY }}/${{ secrets.FORGEJO_USER }}/blog
IMAGE_TAG=${IMAGE_BASE}:${SHORT_SHA}
IMAGE_LATEST=${IMAGE_BASE}:latest
# Auth Conf
mkdir -p /kaniko/.docker
echo "{\"auths\":{\"${{ secrets.FORGEJO_REGISTRY }}\":{\"auth\":\"$(echo -n ${{ secrets.FORGEJO_USER }}:${{ secrets.FORGEJO_TOKEN }} | base64)\"}}}" > /kaniko/.docker/config.json
# Build & push
/kaniko/executor \
--context=git://${{ secrets.FORGEJO_REGISTRY }}/${{ github.repository }}.git \
--git=branch=${{ github.ref_name }} \
--destination=$IMAGE_TAG \
--destination=$IMAGE_LATEST
# Output the specific tag for deployment
echo "image_tag=${IMAGE_TAG}" >> $GITHUB_OUTPUT
deploy:
runs-on: arch-latest
needs: build
steps:
- name: Install dependencies
run: |
pacman -Syu --noconfirm nodejs openssh rsync
- name: Checkout code
uses: actions/checkout@v4
- name: Deploy docker-compose.yml
uses: easingthemes/ssh-deploy@v5.1.0
with:
SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
REMOTE_HOST: ${{ secrets.DEPLOY_IP }}
REMOTE_USER: ${{ secrets.DEPLOY_USER }}
SOURCE: "docker-compose.yml"
TARGET: "/opt/blog/"
- name: Deploy and update container
uses: appleboy/ssh-action@v1.2.3
env:
NEW_IMAGE: ${{ needs.build.outputs.image_tag }}
with:
host: ${{ secrets.DEPLOY_IP }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: NEW_IMAGE
script: |
cd /opt/blog
sed -i "s|image:.*|image: $NEW_IMAGE|" docker-compose.yml
docker pull $NEW_IMAGE
docker compose up -d
# Wait for the image HEALTHCHECK to report healthy.
for i in $(seq 1 18); do
status=$(docker inspect --format '{{.State.Health.Status}}' blog 2>/dev/null || echo missing)
if [ "$status" = "healthy" ]; then
echo "Deployment successful!"
exit 0
fi
sleep 5
done
echo "blog never became healthy (last status: $status)" >&2
docker logs --tail 50 blog
exit 1

View file

@ -0,0 +1,21 @@
name: Test
on:
push:
branches-ignore:
- main
jobs:
check:
runs-on: nix-latest
steps:
- name: Prepare container for actions
run: |
echo "experimental-features = nix-command flakes" >> /etc/nix/nix.conf
nix-env -iA nixpkgs.nodejs_22
- name: Checkout
uses: actions/checkout@v4
- name: Build site
run: nix develop -c zola build

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
public/
.env

21
CLAUDE.md Normal file
View file

@ -0,0 +1,21 @@
# blog.asxp.io — static blog
Zola static site, built in CI into an nginx container, served behind Traefik on the same prod host as asxp.io. Mirrors the conventions of the sibling `../asxpio` repo (Kaniko build, SSH compose deploy, health-wait); see that repo's CLAUDE.md for the pipeline details.
## Writing a post
Add `content/<slug>.md` with TOML front matter (`title`, `date`, optional `description`), push to `main`. CI rebuilds and deploys. Local preview: `nix develop -c zola serve` → http://127.0.0.1:1111.
## Stack
- **Zola** — version pinned in `Dockerfile` (`ghcr.io/getzola/zola`, no `:latest` tag exists); the `flake.nix` dev shell tracks nixpkgs independently. Bump the Dockerfile pin manually.
- Multi-stage `Dockerfile`: `zola build``nginx:alpine` serving the generated `public/`. nginx config in `nginx.conf` (404 page + short static cache).
- `docker-compose.yml`: Traefik labels for `blog.asxp.io`, joins only the external `traefik` network — no storage-stack dependency, fully static, no `.env`.
- Deploy dir on prod: `/opt/blog`. Secrets used (shared with asxpio): `DEPLOY_IP/USER/SSH_KEY`, `FORGEJO_REGISTRY/USER/TOKEN`. Nothing else.
- `templates/` are hand-written Tera (no theme); `static/style.css` reuses the asxp.io dark palette (`#121212` bg, Open Sans + Hack).
## Rules
- Same hard rule as the main site: **never use the words "consulting" or "advisory"** in posts or copy — "services", "engineering", "implementation" instead.
- Blog content is fully public; no entity details beyond what asxp.io already shows (no banking, no registration number).
- Atom feed at `/atom.xml` (`generate_feeds = true` in `config.toml`).

14
Dockerfile Normal file
View file

@ -0,0 +1,14 @@
# Zola publishes no :latest tag; bump the pin manually (flake.nix tracks nixpkgs separately).
FROM ghcr.io/getzola/zola:v0.22.1 AS build
COPY . /project
WORKDIR /project
RUN ["zola", "build"]
FROM nginx:1.28-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /project/public /usr/share/nginx/html
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget -qO /dev/null http://127.0.0.1/ || exit 1

14
config.toml Normal file
View file

@ -0,0 +1,14 @@
base_url = "https://blog.asxp.io"
title = "asxp.io · blog"
description = "Notes on DevOps and infrastructure engineering by Sergei Poljanski."
default_language = "en"
author = "Sergei Poljanski"
compile_sass = false
build_search_index = false
generate_feeds = true
feed_filenames = ["atom.xml"]
[markdown.highlighting]
enabled = true
theme = "ayu-dark"

5
content/_index.md Normal file
View file

@ -0,0 +1,5 @@
+++
sort_by = "date"
template = "index.html"
page_template = "page.html"
+++

11
content/hello-world.md Normal file
View file

@ -0,0 +1,11 @@
+++
title = "Hello, world"
date = 2026-07-16
description = "First post: what this blog is for."
+++
Short posts on DevOps and infrastructure engineering: NixOS, Kubernetes, Docker,
CI/CD, and the occasional war story from running small self-hosted stacks.
The blog itself is [Zola](https://www.getzola.org/), built in CI into an nginx
container and served behind Traefik, next to [asxp.io](https://asxp.io).

21
docker-compose.yml Normal file
View file

@ -0,0 +1,21 @@
services:
blog:
# Image will be dynamically updated during deployment
# Format: registry.example.com/user/blog:sha_tag
build: .
image: blog:latest
container_name: blog
restart: unless-stopped
networks:
- traefik
labels:
- "traefik.enable=true"
- "traefik.http.routers.blog.rule=Host(`blog.asxp.io`)"
- "traefik.http.routers.blog.entrypoints=websecure"
- "traefik.http.routers.blog.tls=true"
- "traefik.http.routers.blog.tls.certresolver=letsencrypt"
- "traefik.http.services.blog.loadbalancer.server.port=80"
networks:
traefik:
external: true

27
flake.lock generated Normal file
View file

@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1784120854,
"narHash": "sha256-KesHgItiZPgGX740axSiQLcIQ8D24MDqNpkKYWIek8k=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "753cc8a3a87467296ddd1fa93f0cc3e81120ee46",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

25
flake.nix Normal file
View file

@ -0,0 +1,25 @@
{
description = "blog.asxp.io static blog (Zola)";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs = { self, nixpkgs }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.${system}.default = pkgs.mkShell {
buildInputs = [
pkgs.zola
];
shellHook = ''
echo "blog dev shell ready"
echo " zola serve - live-reload preview on http://127.0.0.1:1111"
'';
};
};
}

15
nginx.conf Normal file
View file

@ -0,0 +1,15 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
error_page 404 /404.html;
location / {
try_files $uri $uri/ =404;
}
location ~* \.(css|png|svg|xml|ico)$ {
expires 1h;
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
static/hedgehog.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

183
static/style.css Normal file
View file

@ -0,0 +1,183 @@
/* Self-hosted fonts (no CDN). Latin subsets, same files as asxp.io. */
@font-face {
font-family: 'IBM Plex Sans';
src: url('/fonts/ibm-plex-sans-latin-400-normal.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Sans';
src: url('/fonts/ibm-plex-sans-latin-400-italic.woff2') format('woff2');
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Sans';
src: url('/fonts/ibm-plex-sans-latin-600-normal.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Sans';
src: url('/fonts/ibm-plex-sans-latin-700-normal.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Mono';
src: url('/fonts/ibm-plex-mono-latin-400-normal.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
/* Design tokens — kept in sync with asxp.io's public/style.css (source of truth) */
:root {
--bg: #121212;
--surface: #1A1A1A;
--surface-alt: #222222;
--surface-hover: #333333;
--border-subtle: #1E1E1E;
--border: #2A2A2A;
--border-strong: #3A3A3A;
--text: #AAAAAA;
--text-bright: #EEEEEE;
--text-strong: #DDDDDD;
--text-soft: #CCCCCC;
--text-link: #BBBBBB;
--text-dim: #999999;
--text-dimmer: #888888;
--text-faint: #777777;
--text-muted: #666666;
--text-ghost: #555555;
--text-hover: #FFFFFF;
--link-underline: #444444;
--danger: #C97070;
--danger-border: #4A1F1F;
--danger-bg: #2A1414;
--pending: #C9A86A;
--pending-border: #4A3E1F;
--pending-bg: #2A2414;
--paid: #7AB97A;
--paid-border: #234A23;
--paid-bg: #142A14;
--font-sans: 'IBM Plex Sans', sans-serif;
--font-mono: 'IBM Plex Mono', monospace;
}
body {
background-color: var(--bg);
color: var(--text);
font-family: var(--font-sans);
margin: 0;
line-height: 1.6;
}
main {
max-width: 720px;
margin: 0 auto;
padding: 40px 20px 80px;
}
.site-header {
display: flex;
justify-content: space-between;
align-items: baseline;
border-bottom: 1px solid var(--border);
padding-bottom: 12px;
margin-bottom: 36px;
}
.site-title {
font-family: var(--font-mono);
font-size: 1.1rem;
color: var(--text-bright);
text-decoration: none;
}
.site-header nav a {
color: var(--text-faint);
text-decoration: none;
margin-left: 18px;
font-size: 0.9rem;
}
.site-header nav a:hover,
.site-title:hover {
color: var(--text-bright);
}
a {
color: var(--text-soft);
}
a:hover {
color: var(--text-hover);
}
h1 {
font-size: 1.6rem;
font-weight: 600;
color: var(--text-bright);
letter-spacing: -0.01em;
margin: 0 0 4px;
}
article h2 {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-strong);
border-bottom: 1px solid var(--border);
padding-bottom: 6px;
margin: 32px 0 14px;
}
.post-meta {
color: var(--text-muted);
font-size: 0.85rem;
margin: 0 0 28px;
}
ul.post-list {
list-style: none;
padding: 0;
margin: 0;
}
ul.post-list li {
display: flex;
gap: 20px;
align-items: baseline;
padding: 6px 0;
}
ul.post-list time {
font-family: var(--font-mono);
font-size: 0.85rem;
color: var(--text-muted);
flex-shrink: 0;
}
code {
font-family: var(--font-mono);
font-size: 0.92em;
background: var(--surface-alt);
padding: 1px 5px;
}
pre {
background: #1B1B1B;
padding: 12px;
font-size: 14px;
border: 1px solid #020202;
margin: 14px 0;
overflow-x: auto;
}
pre code {
background: none;
padding: 0;
}
blockquote {
border-left: 3px solid var(--border);
margin: 14px 0;
padding: 0 0 0 16px;
color: var(--text-dimmer);
}
img {
max-width: 100%;
}
.footer {
text-align: center;
color: var(--text-ghost);
font-size: 0.8rem;
padding: 20px 0 40px;
}

8
templates/404.html Normal file
View file

@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block title %}404 · {{ config.title }}{% endblock title %}
{% block content %}
<h1>404</h1>
<p>No such page. <a href="/">Back to the index.</a></p>
{% endblock content %}

59
templates/base.html Normal file
View file

@ -0,0 +1,59 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}{{ config.title }}{% endblock title %}</title>
<meta name="description" content="{% block description %}{{ config.description }}{% endblock description %}" />
<meta name="author" content="Sergei Poljanski" />
<link rel="canonical" href="{{ current_url | default(value=config.base_url) }}" />
<meta property="og:type" content="{% block og_type %}website{% endblock og_type %}" />
<meta property="og:site_name" content="{{ config.title }}" />
<meta property="og:title" content="{% block og_title %}{{ config.title }}{% endblock og_title %}" />
<meta property="og:url" content="{{ current_url | default(value=config.base_url) }}" />
{% block extra_head %}{% endblock extra_head %}
<link rel="icon" type="image/png" href="/hedgehog.png" sizes="32x32" />
<link rel="apple-touch-icon" href="/hedgehog.png" />
<link rel="alternate" type="application/atom+xml" title="{{ config.title }}" href="{{ get_url(path='atom.xml') }}" />
<!-- Fonts are self-hosted; see @font-face in style.css -->
<link rel="preload" href="/fonts/ibm-plex-sans-latin-400-normal.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/ibm-plex-sans-latin-600-normal.woff2" as="font" type="font/woff2" crossorigin />
<link rel="stylesheet" href="/style.css" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Blog",
"@id": "{{ config.base_url | safe }}/#blog",
"url": "{{ config.base_url | safe }}",
"name": "{{ config.title }}",
"description": "{{ config.description }}",
"inLanguage": "en",
"author": { "@id": "https://asxp.io/#person" },
"publisher": { "@id": "https://asxp.io/#organization" }
}
</script>
</head>
<body>
<main>
<header class="site-header">
<a class="site-title" href="/">blog.asxp.io</a>
<nav>
<a href="https://asxp.io">asxp.io</a>
<a href="{{ get_url(path='atom.xml') }}">feed</a>
</nav>
</header>
{% block content %}{% endblock content %}
</main>
<div class="footer">
<div class="footer-line">
IE Sergei Poljanski · Tbilisi, Georgia
</div>
</div>
</body>
</html>

12
templates/index.html Normal file
View file

@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block content %}
<ul class="post-list">
{% for page in section.pages %}
<li>
<time datetime="{{ page.date }}">{{ page.date | date(format="%Y-%m-%d") }}</time>
<a href="{{ page.permalink }}">{{ page.title }}</a>
</li>
{% endfor %}
</ul>
{% endblock content %}

19
templates/page.html Normal file
View file

@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block title %}{{ page.title }} · {{ config.title }}{% endblock title %}
{% block og_type %}article{% endblock og_type %}
{% block og_title %}{{ page.title }}{% endblock og_title %}
{% block description %}{{ page.description | default(value=config.description) }}{% endblock description %}
{% block extra_head %}
<meta property="article:published_time" content="{{ page.date }}" />
{% endblock extra_head %}
{% block content %}
<article>
<h1>{{ page.title }}</h1>
<p class="post-meta">
<time datetime="{{ page.date }}">{{ page.date | date(format="%Y-%m-%d") }}</time>
</p>
{{ page.content | safe }}
</article>
{% endblock content %}