This commit is contained in:
@@ -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
@@ -0,0 +1,18 @@
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# build output
|
||||
dist/
|
||||
.astro/
|
||||
|
||||
# env
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# os
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
Generated
+5582
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: "你好,世界 — 关于这个博客"
|
||||
description: "这个博客的中文部分:技术文章、自托管经验、以及我在构建与学习中的记录。"
|
||||
pubDate: 2026-09-06
|
||||
category: notes
|
||||
tags: [intro]
|
||||
lang: zh
|
||||
---
|
||||
|
||||
这是博客的中文部分。我会在这里用中文分享技术文章和自托管的经验。
|
||||
|
||||
英文是主要语言,中文内容会选择性发布——通常是那些对中文读者更有价值的主题。
|
||||
|
||||
## 关于我
|
||||
|
||||
我是一名全栈开发者兼 DevOps 工程师,base 在马来西亚。我构建 Web 应用、
|
||||
自托管基础设施(约 140 个容器),并运营一个面向马来西亚中小企业的邮箱托管业务。
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
·
|
||||
<a href="https://git.hoelee.com/hoelee" target="_blank" rel="noopener">git.hoelee.com</a>
|
||||
</p>
|
||||
</BaseLayout>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user