commit a4b677f898471d03a4b2b691b66438523449eac1 Author: Sergei Poljanski Date: Thu Jul 16 19:51:31 2026 +0400 blog: Zola site, nginx image, Forgejo deploy pipeline diff --git a/.forgejo/workflows/deploy.yaml b/.forgejo/workflows/deploy.yaml new file mode 100644 index 0000000..dc5a853 --- /dev/null +++ b/.forgejo/workflows/deploy.yaml @@ -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 diff --git a/.forgejo/workflows/test.yaml b/.forgejo/workflows/test.yaml new file mode 100644 index 0000000..8f80b8d --- /dev/null +++ b/.forgejo/workflows/test.yaml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e292d34 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +public/ +.env diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..646e4a3 --- /dev/null +++ b/CLAUDE.md @@ -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/.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`). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dd0162a --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..59f8ae5 --- /dev/null +++ b/config.toml @@ -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" diff --git a/content/_index.md b/content/_index.md new file mode 100644 index 0000000..6eae933 --- /dev/null +++ b/content/_index.md @@ -0,0 +1,5 @@ ++++ +sort_by = "date" +template = "index.html" +page_template = "page.html" ++++ diff --git a/content/hello-world.md b/content/hello-world.md new file mode 100644 index 0000000..4aa4780 --- /dev/null +++ b/content/hello-world.md @@ -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). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b0a5495 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..5b993c3 --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..f148a99 --- /dev/null +++ b/flake.nix @@ -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" + ''; + }; + }; +} diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..30fd3c2 --- /dev/null +++ b/nginx.conf @@ -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; + } +} diff --git a/static/fonts/ibm-plex-mono-latin-400-normal.woff2 b/static/fonts/ibm-plex-mono-latin-400-normal.woff2 new file mode 100644 index 0000000..0804aaf Binary files /dev/null and b/static/fonts/ibm-plex-mono-latin-400-normal.woff2 differ diff --git a/static/fonts/ibm-plex-sans-latin-400-italic.woff2 b/static/fonts/ibm-plex-sans-latin-400-italic.woff2 new file mode 100644 index 0000000..cf026fe Binary files /dev/null and b/static/fonts/ibm-plex-sans-latin-400-italic.woff2 differ diff --git a/static/fonts/ibm-plex-sans-latin-400-normal.woff2 b/static/fonts/ibm-plex-sans-latin-400-normal.woff2 new file mode 100644 index 0000000..f0ee65d Binary files /dev/null and b/static/fonts/ibm-plex-sans-latin-400-normal.woff2 differ diff --git a/static/fonts/ibm-plex-sans-latin-600-normal.woff2 b/static/fonts/ibm-plex-sans-latin-600-normal.woff2 new file mode 100644 index 0000000..08c0d5a Binary files /dev/null and b/static/fonts/ibm-plex-sans-latin-600-normal.woff2 differ diff --git a/static/fonts/ibm-plex-sans-latin-700-normal.woff2 b/static/fonts/ibm-plex-sans-latin-700-normal.woff2 new file mode 100644 index 0000000..953f94f Binary files /dev/null and b/static/fonts/ibm-plex-sans-latin-700-normal.woff2 differ diff --git a/static/hedgehog.png b/static/hedgehog.png new file mode 100644 index 0000000..950bccb Binary files /dev/null and b/static/hedgehog.png differ diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..11a9b70 --- /dev/null +++ b/static/style.css @@ -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; +} diff --git a/templates/404.html b/templates/404.html new file mode 100644 index 0000000..85bb278 --- /dev/null +++ b/templates/404.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} + +{% block title %}404 · {{ config.title }}{% endblock title %} + +{% block content %} +

404

+

No such page. Back to the index.

+{% endblock content %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..9311c5f --- /dev/null +++ b/templates/base.html @@ -0,0 +1,59 @@ + + + + + + + {% block title %}{{ config.title }}{% endblock title %} + + + + + + + + + {% block extra_head %}{% endblock extra_head %} + + + + + + + + + + + + + +
+ + {% block content %}{% endblock content %} +
+ + + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..1745ecc --- /dev/null +++ b/templates/index.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% block content %} + +{% endblock content %} diff --git a/templates/page.html b/templates/page.html new file mode 100644 index 0000000..7b57d50 --- /dev/null +++ b/templates/page.html @@ -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 %} + +{% endblock extra_head %} + +{% block content %} +
+

{{ page.title }}

+ + {{ page.content | safe }} +
+{% endblock content %}