Every now and then you look at your monthly Azure bill and ask yourself: do I really need all this?
That was the question that started this whole project. My old domain was running on a classic Azure stack — an App Service hosting a WordPress PHP application, a MySQL Flexible Server for the database, Application Insights for telemetry, and a handful of supporting resources. It worked fine. But a personal blog that publishes a handful of posts per year does not need a constantly-running web server and a relational database. The cost was real; the need for dynamic infrastructure was not.
This post tells the full story of how I migrated the site to a completely static architecture: no server, no database, no PHP runtime, just files served from the edge at a fraction of the cost.
Why dynamic was the wrong choice for a blog
WordPress is a fantastic platform and it has powered the internet for decades. But WordPress is inherently dynamic: every page request hits PHP, which queries a database, assembles HTML, and returns it. That model makes sense when content changes constantly or when logged-in users interact with the site. For a personal blog that rarely updates and has no user accounts, it is massive overkill.
The problems stack up quickly:
- Cost: an App Service plan plus a managed MySQL instance runs continuously whether you post once a week or once a year
- Maintenance: WordPress core, themes and plugins all need regular updates; an unpatched installation is a security liability
- Performance: a cold PHP app on a Basic tier plan has noticeable response latency compared to a CDN-cached static file
- Fragility: a database outage or a failed plugin update can take the whole site offline
The alternative is a static site: HTML, CSS and JavaScript files that are pre-built once and served directly from a content delivery network edge. There is no runtime to maintain, no database to back up, and the cost on Azure Static Web Apps is essentially zero for a low-traffic personal site.
Choosing the right tools
Before writing a single line of code, I needed to decide on a framework. The requirements were simple:
- Write posts in Markdown — readable in a text editor, version-controllable in Git
- Keep the same URL structure as WordPress (
/2026/02/01/post-slug/) - Support category and tag archive pages
- Work with a CI/CD pipeline so pushing to
mainautomatically deploys the site
Astro fit perfectly. It is a modern static site builder designed around content-first workflows. It generates pure HTML at build time, ships zero JavaScript by default (you add it only where you need it), and its content collections give you a type-safe way to manage Markdown posts with frontmatter schemas.
For hosting I chose Azure Static Web Apps, which provides:
- Free tier for hobby projects with generous bandwidth limits
- Built-in GitHub Actions integration — push to
main, site deploys automatically - Custom domain support with automatic TLS certificates
- Global CDN edge delivery
Step 1 — Scaffold the Astro project
I created a fresh Astro project in an empty folder and configured it for fully static output:
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
output: 'static',
trailingSlash: 'always',
});
Then I defined two content collections — posts and pages — with Zod schemas that validate frontmatter fields at build time. This catches typos and missing fields before the site ever deploys.
// src/content.config.ts
const postCollection = defineCollection({
schema: z.object({
title: z.string(),
pubDate: z.coerce.date(),
categories: z.array(z.string()).default([]),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false)
})
});
The routing layer mirrors exactly what WordPress produced. Astro’s file-based routing made this straightforward:
src/pages/
index.astro → /
[slug].astro → /about/, /contact/, …
blog/index.astro → /blog/
[year]/[month]/[day]/[slug].astro → /2026/02/01/my-post/
[year]/[month]/index.astro → /2026/02/
category/[slug].astro → /category/azure/
tag/[slug].astro → /tag/static-site/
Every route uses getStaticPaths() to enumerate all valid paths from the content collection at build time.
Step 2 — Import content from WordPress
The existing content — eleven posts and five pages — was already published on the live WordPress site. Rather than copy-pasting, I wrote a Node.js importer script that pulled everything from the WordPress REST API:
npm run import:wordpress
The script:
- Fetches all posts and pages from
https://oldsite.example/wp-json/wp/v2/ - Converts each item to a Markdown file with the correct frontmatter
- Downloads all media from
wp-content/uploadsintopublic/uploads/ - Rewrites image paths in the post body so they point to the local copy
After running the importer, all content was inside the Git repository. The WordPress site was still running — this was a parallel migration, not a cut-and-paste — which meant there was zero downtime risk.
Step 3 — Push to GitHub and wire up deployment
I created a private GitHub repository and pushed the project:
git init
git add .
git commit -m "Initial static site migration from WordPress"
git remote add origin https://github.com/your-org/your-static-blog.git
git push -u origin main
Then I provisioned the Azure Static Web App with a PowerShell script that called the Azure CLI, retrieved the deployment token, and stored it as a GitHub Actions secret automatically. The GitHub Actions workflow is minimal:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
env:
PUBLIC_CONTACT_PAYLOAD_B64: ${{ secrets.PUBLIC_CONTACT_PAYLOAD_B64 }}
PUBLIC_CONTACT_PAYLOAD_SHIFT: ${{ secrets.PUBLIC_CONTACT_PAYLOAD_SHIFT }}
- uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.YOUR_SWA_DEPLOYMENT_TOKEN_SECRET }}
action: upload
app_location: /
output_location: dist
Every push to main now triggers a build and deploy. The whole pipeline takes under two minutes.
Step 4 — Solve the contact form problem (without leaking your email)
The imported contact page still contained old WordPress markup pointing to the original site’s Jetpack form. Static sites cannot process form submissions server-side, so I needed an alternative.
The simplest working solution is a mailto: link — but I did not want my email address sitting in plain text in the repository or the rendered HTML where scrapers would find it immediately.
The solution was a two-layer obfuscation scheme:
- Apply a character shift to the base64-encoded email address
- Store the shifted payload in GitHub Actions secrets (
PUBLIC_CONTACT_PAYLOAD_SHIFT+PUBLIC_CONTACT_PAYLOAD_B64) - At build time, Astro reads the secret from the environment and passes it to the component as a prop
- A small inline
<script>reverses the shift and decodes base64 in the browser, then sets thehrefon the anchor
// In the browser, at click time:
const unshifted = unshiftPrintableAscii(encodedEmail, shiftValue);
const email = atob(unshifted).trim();
link.setAttribute('href', `mailto:${email}`);
The raw email address never appears in the repository, the built HTML, or the network response. It only exists in the browser’s memory after the user visits the page.
Note: this is obfuscation, not encryption. A determined attacker with DevTools open can decode it. But it reliably defeats automated scrapers that harvest plain-text addresses from HTML.
Step 5 — DNS cutover
With the static site validated on the Azure Static Web Apps default hostname (your-static-site.region.azurestaticapps.net), I was ready to switch traffic.
Because the domain was already managed in Azure DNS, I could do the entire cutover from the CLI without touching a DNS provider web console.
www subdomain — straightforward CNAME delegation:
# Point www at the Static Web App
az network dns record-set cname set-record \
--zone-name example.com \
--resource-group dns-resource-group-placeholder \
--record-set-name www \
--cname your-static-site.region.azurestaticapps.net
# Associate the custom domain in SWA (validates via CNAME)
az staticwebapp hostname set \
--name swa-example-prod \
--hostname www.example.com \
--validation-method cname-delegation
SWA validated the CNAME within a few minutes and issued a TLS certificate automatically. www.example.com was live on the new site.
Apex domain — more nuanced. Azure DNS does not support ALIAS/ANAME flattening at the zone apex, so we cannot CNAME the bare domain to the Static Web App. Instead, the apex A record temporarily keeps pointing at the old App Service while Azure Static Web Apps validates ownership via a TXT record:
# Add the SWA ownership token to the apex TXT record set
az network dns record-set txt add-record \
--zone-name example.com \
--resource-group dns-resource-group-placeholder \
--record-set-name "@" \
--value "_verification-token-from-swa"
While that validation completes, the old WordPress app was patched to issue a 301 Moved Permanently to https://www.example.com for every request — so all traffic landing on the apex is immediately forwarded to the new static site.
Where things stand now
| What | Status |
|---|---|
www.example.com | ✅ Live on Azure Static Web Apps |
| GitHub Actions deployment | ✅ Push-to-deploy from main |
| TLS certificate on www | ✅ Issued automatically by SWA |
| Contact page | ✅ Obfuscated mailto link |
Apex (example.com) | ⏳ 301 redirect to www while SWA validates |
| Old WordPress app | ⏳ Still running, redirecting; ready to stop once apex cutover completes |
| Old MySQL database | ⏳ To be stopped once no traffic remains on old stack |
What this migration actually cost
In terms of time: roughly one focused day of work spread across a few sessions.
In terms of money: the new stack costs approximately €0/month on Azure Static Web Apps free tier. The old stack (App Service Basic + MySQL Flexible) was running around €40–60/month depending on the tier.
The repository is private on GitHub (free). The DNS zone is staying in Azure DNS — that is a fixed ~€0.50/month regardless.
Key takeaways
- You do not need a server to run a blog. If your content is mostly static and you do not have logged-in users, a static site generator is a better fit than WordPress.
- Git is a better CMS than a database for this use case. Every post is a Markdown file. You can write in VS Code, review diffs, revert changes, and collaborate with pull requests.
- Astro is a great choice for content-heavy sites. Zero JavaScript by default, fast builds, excellent TypeScript support, and a content collection system that gives you schema validation for free.
- Azure Static Web Apps is genuinely free for hobby projects and the GitHub integration is seamless.
- DNS cutover does not have to be scary. Doing it in stages — www first, apex second with a redirect in place — means you can validate each step before committing.
If you want to do this for your own site, all the tooling — the importer script, the Astro config, the GitHub Actions workflow and the Azure provisioning script — should live in your own private repository (for example, https://github.com/your-org/your-static-blog).