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.

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
.mdfile insrc/pages/blog/. No database, no CMS UI, no schema migrations. - Component model —
.astrofiles 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 optimization —
astro:assetshandles lazy loading and format conversion automatically. - RSS out of the box — the Astrofy template I’m using ships with
rss.xml.jspre-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.

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.

Custom Domain in 5 Minutes
By default GitHub Pages gives you <username>.github.io/<repo>. Setting a custom domain is a two-step process:
- Add a
CNAMEfile to thepublic/directory containing your domain (portfolio.hagzag.com). - In your DNS provider (Cloudflare in my case), add a
CNAMErecord pointing tohagzag.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:
- Create
src/pages/blog/YYYY-MM-DD-post-title.md - Write in Markdown — code blocks, headers, links, all native
git add . && git commit -m "post: title" && git push- GitHub Action runs (~60 seconds)
- 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.

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
- Astro docs — GitHub Pages deployment
- Astrofy template — the foundation I built on
- withastro/github-pages — official Astro GitHub Action
- Astro Content Collections — the next evolution
- portfolio.hagzag.com — live site
Discussion