Initial commit: Astro blog scaffold with CI/CD
Deploy / build (push) Waiting to run

This commit is contained in:
2026-09-06 04:18:16 +08:00
commit 8372d5e659
15 changed files with 5976 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
name: Deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Deploy to unRaid docroot
run: |
rm -rf /mnt/user/docker/blog/html/*
cp -r dist/* /mnt/user/docker/blog/html/
+18
View File
@@ -0,0 +1,18 @@
# dependencies
node_modules/
# build output
dist/
.astro/
# env
.env
.env.*
# os
.DS_Store
Thumbs.db
# logs
*.log
npm-debug.log*
+37
View File
@@ -0,0 +1,37 @@
# hoelee-blog
My personal technical blog — where I write about what I build and learn.
Built with [Astro](https://astro.build) + Markdown, deployed via self-hosted Gitea CI/CD to an nginx container on my unRaid server, served through Cloudflare.
## Stack
- **Framework:** Astro 5 (static output)
- **Content:** Markdown + MDX, versioned in git
- **i18n:** English (default) + Chinese (`zh`)
- **CI/CD:** Gitea Actions → `act_runner` on unRaid
- **Host:** nginx on unRaid → Cloudflare tunnel → Cloudflare CDN
## Structure
```
src/
content/
posts/ # blog posts (en + zh)
pages/
index.astro # homepage
posts/[...slug].astro # post template
about.astro
layouts/
BaseLayout.astro
```
## Write a post
1. Create `src/content/posts/<slug>.md` with frontmatter.
2. Push to `main`.
3. CI builds and deploys automatically.
## Categories
engineering · devops · ai · web3 · tutorials · case-studies · notes
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'astro/config';
// https://astro.build/config
export default defineConfig({
site: 'https://blog.hoelee.com',
trailingSlash: 'always',
i18n: {
defaultLocale: 'en',
locales: ['en', 'zh'],
routing: {
prefixDefaultLocale: false,
},
},
});
+5582
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
{
"name": "hoelee-blog",
"type": "module",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"astro": "^5.5.0"
}
}
+26
View File
@@ -0,0 +1,26 @@
import { defineCollection, z } from 'astro:content';
const posts = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
category: z.enum([
'engineering',
'devops',
'ai',
'web3',
'tutorials',
'case-studies',
'notes',
]),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
lang: z.enum(['en', 'zh']).default('en'),
ogImage: z.string().optional(),
}),
});
export const collections = { posts };
+17
View File
@@ -0,0 +1,17 @@
---
title: "你好,世界 — 关于这个博客"
description: "这个博客的中文部分:技术文章、自托管经验、以及我在构建与学习中的记录。"
pubDate: 2026-09-06
category: notes
tags: [intro]
lang: zh
---
这是博客的中文部分。我会在这里用中文分享技术文章和自托管的经验。
英文是主要语言,中文内容会选择性发布——通常是那些对中文读者更有价值的主题。
## 关于我
我是一名全栈开发者兼 DevOps 工程师,base 在马来西亚。我构建 Web 应用、
自托管基础设施(约 140 个容器),并运营一个面向马来西亚中小企业的邮箱托管业务。
+44
View File
@@ -0,0 +1,44 @@
---
title: "How I host this blog: Astro, Gitea Actions, and self-hosted CI/CD"
description: "A walkthrough of the end-to-end pipeline that builds and serves this site — git-as-CMS, a self-hosted runner on my unRaid server, and Cloudflare in front."
pubDate: 2026-09-06
category: case-studies
tags: [astro, gitea, ci-cd, self-hosting, docker, cloudflare]
lang: en
---
This blog is itself a project I built to demonstrate the kind of work I do.
Here's the full pipeline, so the architecture is transparent.
## The stack
- **Astro 5** — static site generated from Markdown.
- **Git as the CMS** — every post is a `.md` file with YAML frontmatter, versioned in [git.hoelee.com](https://git.hoelee.com/hoelee/hoelee-blog).
- **Gitea Actions** — a self-hosted CI runner (`act_runner`) on my unRaid server builds the site on every push to `main`.
- **nginx** — a dedicated container serves the static `dist/` output.
- **Cloudflare** — the tunnel exposes it publicly, and the CDN caches everything.
## Why static + git?
A technical blog has no dynamic content. Writing in Markdown, reviewing via
pull request, and rebuilding on merge gives me:
1. Full version history of every word I publish.
2. No database or admin panel to secure or patch.
3. Near-perfect performance — pre-built HTML, no server-side rendering.
## The deploy flow
```
git push → Gitea webhook → act_runner picks up the job
→ npm ci → astro build → copy dist/* to docroot → nginx serves it
```
The whole thing runs on hardware in my homelab, which is exactly the point —
this is a live demo of the DevOps work I describe elsewhere.
## Coming up
Future posts will cover the individual pieces in depth: the `act_runner`
container setup, the Cloudflare cache rules, and the i18n routing for the
Chinese side of this site.
+75
View File
@@ -0,0 +1,75 @@
---
interface Props {
title: string;
description?: string;
lang?: string;
}
const { title, description = 'Personal blog of Hoelee — engineering, DevOps, AI, and self-hosting.', lang = 'en' } = Astro.props;
---
<!doctype html>
<html lang={lang}>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
<style>
:root {
--fg: #1a1a1a;
--muted: #6b7280;
--accent: #2563eb;
--bg: #ffffff;
--code-bg: #f3f4f6;
}
@media (prefers-color-scheme: dark) {
:root {
--fg: #e5e7eb;
--muted: #9ca3af;
--accent: #60a5fa;
--bg: #111827;
--code-bg: #1f2937;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: var(--fg);
background: var(--bg);
}
main { max-width: 720px; margin: 0 auto; padding: 2rem 1.5rem 4rem; }
header.nav {
max-width: 720px; margin: 0 auto; padding: 1.5rem 1.5rem 0;
display: flex; gap: 1rem; align-items: baseline;
}
header.nav a { color: var(--muted); text-decoration: none; }
header.nav a:hover { color: var(--accent); }
header.nav .brand { font-weight: 700; color: var(--fg); margin-right: auto; }
h1, h2, h3 { line-height: 1.25; }
a { color: var(--accent); }
article a { text-decoration: underline; }
code { background: var(--code-bg); padding: 0.15em 0.4em; border-radius: 4px; font-size: 0.9em; }
pre { background: var(--code-bg); padding: 1rem; border-radius: 8px; overflow-x: auto; }
pre code { background: none; padding: 0; }
time { color: var(--muted); font-size: 0.9rem; }
.post-list { list-style: none; padding: 0; }
.post-list li { margin-bottom: 1.5rem; }
.post-list h2 { margin: 0 0 0.25rem; }
.tag { display: inline-block; background: var(--code-bg); color: var(--muted); padding: 0.1em 0.5em; border-radius: 999px; font-size: 0.8rem; margin-right: 0.4rem; }
</style>
</head>
<body>
<header class="nav">
<a class="brand" href="/">hoelee.dev</a>
<a href="/about/">about</a>
<a href="/posts/">posts</a>
</header>
<main>
<slot />
</main>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="About — hoelee.dev">
<h1>About</h1>
<p>
I'm Hoelee, a full-stack developer and DevOps engineer based in Malaysia.
I build web applications (PHP/CodeIgniter, Java/Spring, JavaScript/TypeScript/React),
self-host a ~140-container homelab, and run a small email-hosting business
for Malaysian SMEs.
</p>
<p>
This blog is where I document what I build — from self-hosted CI/CD and
Docker orchestration to Web3 experiments and AI automation. If you're a
recruiter or a fellow builder, the posts here are my living portfolio.
</p>
<p>
<a href="https://www.hoelee.com" target="_blank" rel="noopener">www.hoelee.com</a>
&middot;
<a href="https://git.hoelee.com/hoelee" target="_blank" rel="noopener">git.hoelee.com</a>
</p>
</BaseLayout>
+35
View File
@@ -0,0 +1,35 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { getCollection } from 'astro:content';
const posts = (await getCollection('posts', ({ data }) => !data.draft))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
---
<BaseLayout title="hoelee.dev — personal blog">
<h1>hoelee.dev</h1>
<p>
I'm Hoelee — a full-stack developer and DevOps engineer. I build web apps,
self-host infrastructure, and automate things. Here I write about what I
learn and ship.
</p>
<h2>Latest posts</h2>
{posts.length === 0 ? (
<p><em>No posts yet — check back soon.</em></p>
) : (
<ul class="post-list">
{posts.slice(0, 10).map((post) => (
<li>
<a href={`/posts/${post.slug}/`}>
<h2>{post.data.title}</h2>
</a>
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
</time>
<p>{post.data.description}</p>
</li>
))}
</ul>
)}
</BaseLayout>
+27
View File
@@ -0,0 +1,27 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getCollection, render } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('posts', ({ data }) => !data.draft);
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
---
<BaseLayout title={`${post.data.title} — hoelee.dev`} description={post.data.description} lang={post.data.lang}>
<article>
<h1>{post.data.title}</h1>
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
</time>
<span class="tag">{post.data.category}</span>
{post.data.tags.map((tag) => <span class="tag">{tag}</span>)}
<Content />
</article>
</BaseLayout>
+30
View File
@@ -0,0 +1,30 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getCollection } from 'astro:content';
const posts = (await getCollection('posts', ({ data }) => !data.draft))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
---
<BaseLayout title="Posts — hoelee.dev">
<h1>Posts</h1>
{posts.length === 0 ? (
<p><em>No posts yet.</em></p>
) : (
<ul class="post-list">
{posts.map((post) => (
<li>
<a href={`/posts/${post.slug}/`}>
<h2>{post.data.title}</h2>
</a>
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
</time>
<span class="tag">{post.data.category}</span>
{post.data.tags.map((tag) => <span class="tag">{tag}</span>)}
<p>{post.data.description}</p>
</li>
))}
</ul>
)}
</BaseLayout>
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}