TL;DR

I migrated my personal portfolio and blog to Astro deployed on GitHub Pages. The result: zero hosting costs, a git push deployment workflow, Markdown-first content, and a lighthouse score that embarrasses most enterprise React apps. Here’s how it works and why I chose this stack.

Introduction

There’s a particular kind of friction that stops developers from blogging. Not writer’s block — infrastructure block. “I’ll set up the blog once I figure out the right hosting platform.” “Once I finish customizing the theme.” “Once I migrate from Hugo/Jekyll/WordPress/Ghost.” Years pass.

I’ve been writing on Medium as my primary publishing channel for a while, and it’s great for reach. But I wanted a home I owned — a canonical URL, a portfolio that reflects my actual work, and a setup where writing a new post is as low-friction as opening a .md file.

Enter: Astro + GitHub Pages. Deployed at portfolio.hagzag.com.

my blog with astro

Why Astro?

I’ve used Hugo for years and have strong opinions about it. Hugo is fast, but the templating language is a tax you pay on every customization. I looked at Next.js — too much JavaScript for a content-heavy site where most pages are static. Gatsby — don’t get me started.

Astro hits a different sweet spot. It’s built around the principle of islands architecture: ship zero JavaScript by default, opt into interactivity only where you need it. For a blog and portfolio, that’s exactly the right model.

What actually sold me:

  • Markdown-first — every blog post is a .md file in src/pages/blog/. No database, no CMS UI, no schema migrations.
  • Component model.astro files feel like clean HTML templates. You can drop in a React or Svelte component when you genuinely need it, without committing your whole site to that framework.
  • Built-in image optimizationastro:assets handles lazy loading and format conversion automatically.
  • RSS out of the box — the Astrofy template I’m using ships with rss.xml.js pre-wired.

I started from the Astrofy template by @manuelernestog — a battle-tested Astro + TailwindCSS + DaisyUI setup with a blog, CV, and projects section already built. Forked it, customized it, done.

Astro island architecture diagram

The Repository Structure

The layout is minimal and predictable:

portfolio/
├── src/
│   ├── components/        # Reusable .astro components
│   ├── layouts/
│   │   ├── BaseLayout.astro
│   │   └── PostLayout.astro
│   └── pages/
│       ├── index.astro
│       ├── cv.astro
│       ├── projects.astro
│       └── blog/
│           ├── [page].astro   # Paginated blog index
│           └── *.md           # Individual posts (this file lives here)
├── public/                # Static assets, favicon, images
├── astro.config.mjs
├── tailwind.config.cjs
└── package.json

Each post is a Markdown file with frontmatter:

---
layout: "../../layouts/PostLayout.astro"
title: "My Post Title"
description: "A concise description"
pubDate: "Apr 24 2026"
heroImage: "/images/blog/cover.png"
tags: ["devops", "platform-engineering"]
---

Post content starts here...

That’s it. No build configuration per post. No import statements. Just write.

GitHub Pages + GitHub Actions: The Deployment Pipeline

The CI/CD side couldn’t be simpler. Astro maintains an official GitHub Action that handles the full build-and-deploy cycle.

The workflow lives at .github/workflows/deploy.yml:

name: Deploy to GitHub Pages

on:
  push:
    branches: [ main ]
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: "pages"
  cancel-in-progress: false

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - name: Build Astro site
        run: npm run build
      - uses: actions/upload-pages-artifact@v3
        with:
          path: ./dist

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - uses: actions/deploy-pages@v4
        id: deployment

Push to main → GitHub Actions builds the static site → publishes to the gh-pages branch or directly via the Pages deployment environment. On GitHub, set Pages → Source → GitHub Actions, and you’re done.

GitHub Pages + GitHub Actions: The Deployment Pipeline

Custom Domain in 5 Minutes

By default GitHub Pages gives you <username>.github.io/<repo>. Setting a custom domain is a two-step process:

  1. Add a CNAME file to the public/ directory containing your domain (portfolio.hagzag.com).
  2. In your DNS provider (Cloudflare in my case), add a CNAME record pointing to hagzag.github.io.

Astro’s astro.config.mjs also needs the site value aligned:

// astro.config.mjs
export default defineConfig({
  site: 'https://portfolio.hagzag.com',
  // base is not needed when using a custom domain
  integrations: [tailwind(), sitemap()],
});

GitHub handles the TLS cert automatically. Total cost: $0.

The Writing Workflow

This is where it pays off day-to-day. My writing flow:

  1. Create src/pages/blog/YYYY-MM-DD-post-title.md
  2. Write in Markdown — code blocks, headers, links, all native
  3. git add . && git commit -m "post: title" && git push
  4. GitHub Action runs (~60 seconds)
  5. Post is live at portfolio.hagzag.com/blog/post-slug

For cross-posting to Medium, I copy the Markdown, paste it into Medium’s editor (it imports Markdown reasonably well), and add the canonical URL pointing back to my portfolio. Best of both worlds: Medium’s distribution, my domain’s authority.

The Writing Workflow

What I’d Do Differently

A few honest notes after running this in production:

Image management is the friction point. Images live in public/ and you reference them by path. There’s no asset pipeline that auto-generates cover images. I’ve been generating covers manually and would eventually like an n8n workflow to auto-generate them from post frontmatter.

The Astrofy template’s blog pagination uses dynamic routes that are incompatible with SSR deployment configs — stick to the static output mode (output: 'static'). I learned this the hard way.

Astro Content Collections (available since Astro 2.x) are a cleaner pattern than src/pages/blog/*.md for large sites. Content Collections add type safety to your frontmatter schema. For my current post volume, the simpler approach works fine, but I’ll migrate when the site grows.

Conclusion

The stack I landed on — Astro + Astrofy template + GitHub Pages + GitHub Actions + custom domain — is the lowest-friction developer blog setup I’ve used. It costs nothing to run, deploys in under a minute, and the content format is plain Markdown stored in git.

If you’re a DevOps or platform engineer who keeps deferring that personal blog because the setup feels like work: this stack removes every excuse. Fork Astrofy, update the content, push — you’re live before your next coffee gets cold.


Resources