rchou 0eaa37fb5a
Deploy on push / deploy (push) Successful in 16s
fix formatting
2026-06-19 23:07:29 -07:00
2026-06-10 22:46:25 -07:00
2026-06-10 22:46:25 -07:00
2026-06-10 22:46:25 -07:00
2026-06-19 23:07:29 -07:00
2026-06-10 22:46:25 -07:00
2026-06-10 22:46:25 -07:00
2026-06-10 22:46:25 -07:00
2026-06-10 22:46:25 -07:00
2026-06-10 22:46:25 -07:00
2026-06-10 22:46:25 -07:00
2026-06-10 22:46:25 -07:00

Ryan's Blog

This repository contains a static blog built with Astro. Markdown files are the source of truth for every post. Astro validates the post metadata, converts the Markdown to HTML, creates the homepage and post routes, and writes the finished website to dist/.

The production site is deployed automatically by Gitea Actions whenever a commit reaches the main branch.

How the site works

The publishing pipeline has four stages:

  1. A post is written as Markdown in src/content/posts/.
  2. Astro reads and validates its frontmatter using src/content.config.ts.
  3. npm run build renders all non-draft posts into static HTML in dist/.
  4. Gitea Actions copies dist/ into the website directory on the VPS.

The VPS serves ordinary HTML, CSS, and image files. Astro and Node.js are only needed while developing or building the site; they do not run as part of the production website.

src/content/posts/my-post.md
             |
             | npm run build
             v
dist/posts/my-post/index.html
             |
             | Gitea Actions and rsync
             v
/home/cab/docker/websites/blog-src/
             |
             | web server
             v
https://the-blog-domain.example/posts/my-post/

Repository structure

.
├── .gitea/workflows/pipeline.yml  Automatic build and deployment workflow
├── assets/                        Images, icons, and the web manifest
├── posts/template.md              Template used when starting a post
├── src/
│   ├── content/posts/             Published posts and drafts in Markdown
│   ├── content.config.ts          Frontmatter schema and content loader
│   ├── layouts/BaseLayout.astro   Shared HTML document, fonts, and icons
│   └── pages/
│       ├── index.astro            Homepage and date ordering
│       └── posts/[slug].astro     Generated page for each published post
├── astro.config.mjs               Astro static-output configuration
├── package.json                   Commands and development dependencies
├── package-lock.json              Exact dependency versions used by CI
├── styles.css                     Site-wide styles
└── tsconfig.json                  TypeScript settings used by Astro

Content and source files

  • src/content/posts/ contains the actual blog posts. Each Markdown file has frontmatter followed by the post body. The filename is for organization; the public URL comes from the slug field.
  • posts/template.md is the starting point for new posts. It is outside the content collection, so it can never accidentally become a page.
  • src/content.config.ts defines the required post metadata. The build fails when metadata is invalid instead of silently publishing a broken page.
  • src/pages/index.astro loads non-draft posts and sorts them by date, newest first. No post list or manual ordering file needs to be maintained.
  • src/pages/posts/[slug].astro creates one static route for every non-draft post. A post with the slug my-post becomes /posts/my-post/.
  • src/layouts/BaseLayout.astro contains shared document markup, metadata, font loading, favicons, and the global stylesheet import.
  • assets/ is Astro's public directory. Its contents are copied to the root of dist/, so assets/cabby.jpg is available as /cabby.jpg.

Generated directories

Do not edit or commit these directories:

  • node_modules/ contains packages installed by npm.
  • .astro/ contains generated content indexes and type information.
  • dist/ is the complete production website generated by npm run build. Deployment copies it to the VPS. It can be deleted and rebuilt at any time.

These directories are listed in .gitignore.

Requirements

Local development requires:

  • Node.js 22.12 or newer
  • npm 9.6 or newer
  • Git

Check the installed versions:

node --version
npm --version
git --version

Initial setup

Clone the repository and install the exact locked dependencies:

git clone <repository-url>
cd blogs
npm ci

Use npm ci for normal work and CI because it reproduces package-lock.json exactly. Use npm install when intentionally adding or updating a dependency.

Development commands

Start the local development server:

npm run dev

Astro prints the local URL, normally http://localhost:4321. The development server watches source and Markdown files and refreshes the site as they change.

Validate the Astro source and content without producing a deployment build:

npm run check

Create a production build:

npm run build

This runs astro check first and then generates the static site in dist/. Always run it before opening a pull request.

Preview the generated production build:

npm run preview

The preview command serves dist/ locally. It is useful for checking the exact files that would be deployed.

Post frontmatter

Every file in src/content/posts/ begins with YAML frontmatter:

---
title: Post title
date: "2026-06-10"
excerpt: A short description shown on the homepage.
slug: post-title
draft: true
---

Frontmatter fields

  • title is required. It is displayed on the homepage, post page, and browser tab.
  • date is required. Use the ISO format YYYY-MM-DD, quoted as a string. The homepage sorts posts by this value from newest to oldest.
  • excerpt may be an empty string. A non-empty excerpt appears on the homepage and is used as the page description.
  • slug is required and becomes the public URL. It may contain lowercase letters, numbers, and single hyphens between words.
slug: the-weight-of-wanting
URL:  /posts/the-weight-of-wanting/

Keep slugs unique. Do not change a published slug without intentionally changing its URL and accepting that old links will stop working.

  • draft should be true while a post is excluded from the homepage and generated routes. Set it to false when the post is ready to publish.

Branches other than main do not deploy, so it is safe to set draft: false on a post branch while testing the final homepage and URL locally.

Markdown formatting

Post bodies use standard Markdown.

Paragraphs

Separate paragraphs with a blank line:

This is one paragraph.

This is another paragraph.

A single newline within normal prose does not create a visible line break. This allows source text to wrap without changing the rendered paragraph.

Headings

# Main heading
## Section heading
### Smaller heading

The post title already appears above the body, so body sections should normally begin with ##.

Emphasis

**bold text**
*italic text*
***bold and italic text***

Do not put spaces inside the markers. Write **bold**, not ** bold **.

[Link text](https://example.com)

> This is a blockquote.

Lists

- First item
- Second item

1. First step
2. Second step

Leave a blank line before a list when it follows a paragraph.

Code

Inline code uses backticks:

Run `npm run build` before merging.

Use a fenced block for multiple lines:

```js
const message = "Hello";
console.log(message);
```

Horizontal rules

---

Place a blank line before and after a horizontal rule. The first --- at the top of a post belongs to YAML frontmatter and has a different purpose.

Poetry and intentional line breaks

Normal Markdown joins single source newlines into a paragraph. End a line with a backslash when the rendered text must break at that exact point:

I lost something irreplaceable,\
and I was the one who ended it.

Use a blank line between stanzas:

The first line,\
the second line.

A new stanza begins here.

This keeps normal prose correctly formatted while still supporting poetry.

Creating and publishing a new post

The standard publishing workflow uses one Git branch and one Gitea pull request per post. Branches use the naming convention post/<title-slug>.

Merging the pull request into main publishes the post automatically.

1. Update local main

Start from a clean, current main branch:

git switch main
git pull --ff-only origin main
git status

Do not start a post branch while unrelated uncommitted changes are present.

2. Create the post branch

Choose a short lowercase slug and create the branch:

git switch -c post/my-new-post

Examples:

post/my-new-post
post/notes-on-community
post/the-cost-of-convenience

3. Copy the post template

Copy the template into the content collection:

cp posts/template.md src/content/posts/my-new-post.md

The Markdown filename should normally match the slug because that makes the repository easier to navigate, even though Astro does not require it.

Edit the frontmatter and write the post:

---
title: My New Post
date: "2026-06-10"
excerpt: A concise summary for the homepage.
slug: my-new-post
draft: true
---

4. Preview while writing

Install dependencies if necessary and start Astro:

npm ci
npm run dev

With draft: true, the post is intentionally absent from the website. Set draft: false when you want to test the homepage card and generated route. This does not deploy anything while working on a non-main branch.

Check:

  • title, date, and excerpt;
  • homepage ordering;
  • /posts/my-new-post/;
  • paragraphs and poetic line breaks;
  • links, lists, quotations, and code;
  • desktop and narrow-screen layouts.

5. Validate the production build

Before committing:

npm run build
npm run preview

The build must complete without errors. Confirm that the post appears in dist/posts/my-new-post/index.html when draft is false.

6. Commit and push the branch

Review what will be committed:

git status
git diff

Then commit and push:

git add src/content/posts/my-new-post.md
git commit -m "Add My New Post"
git push -u origin post/my-new-post

If the post includes new images, add those files from assets/ to the same commit.

7. Open a Gitea pull request

In Gitea:

  1. Open the repository.
  2. Create a pull request from post/my-new-post into main.
  3. Review the changed Markdown and confirm draft: false.
  4. Confirm the branch builds locally and any configured checks pass.
  5. Merge the pull request.

The merge pushes a new commit to main. That push triggers the deployment workflow. No [deploy] text or manual deployment command is required.

8. Confirm deployment

Open the repository's Actions page in Gitea and inspect the deployment job. It should complete these steps:

  1. check out the merged commit;
  2. run npm ci;
  3. run npm run build;
  4. mirror dist/ into /deploy/websites/blog-src/.

After the job succeeds, open the homepage and the new post URL in a browser.

9. Clean up the branch

Gitea may offer to delete the source branch after merging. If it does not, clean it up manually:

git switch main
git pull --ff-only origin main
git branch -d post/my-new-post
git push origin --delete post/my-new-post

Deleting the branch does not delete the post because the merged post is now part of main.

Updating a pull request

Continue editing on the same post branch:

git switch post/my-new-post

Commit and push additional changes:

git add src/content/posts/my-new-post.md
git commit -m "Revise My New Post"
git push

Gitea adds those commits to the existing pull request automatically.

Updating a branch from main

If main changes while a post is being written, update the branch before merging:

git switch main
git pull --ff-only origin main
git switch post/my-new-post
git merge main

Resolve any conflicts, then validate and push:

npm run build
git push

This repository uses a normal merge in the documented workflow because it is easy to understand and does not rewrite a branch that may already be under review.

Editing an existing post

Use the same branch and pull-request process:

git switch main
git pull --ff-only origin main
git switch -c post/revise-my-post

Edit the existing file, run npm run build, commit it, push the branch, and open a pull request into main.

Avoid changing the slug unless a URL change is intentional.

Removing a post

Choose one of two approaches on a branch:

  • Set draft: true to keep the Markdown in Git but remove it from the website.
  • Delete the Markdown file to remove both the source and generated page.

After the change is merged, rsync --delete removes the old generated route from the deployment directory.

Automatic deployment

The workflow is defined in .gitea/workflows/pipeline.yml and runs on every push to main.

Pull request merged
        |
        v
Push to main
        |
        v
Gitea creates an Actions job
        |
        v
alpine-rsync runner starts a temporary job container
        |
        v
npm ci
        |
        v
npm run build
        |
        v
rsync dist/ /deploy/websites/blog-src/

Why deployment does not use SSH

Gitea, the Actions runner, and the website are hosted on the same VPS. Runner job containers receive this bind mount:

VPS host:     /home/cab/docker/websites
Job container: /deploy/websites

Writing to this path in the job container:

/deploy/websites/blog-src

therefore writes directly to this path on the VPS:

/home/cab/docker/websites/blog-src

SSH would send files over the network only to return to the same server. The bind mount is simpler and requires no deployment key or Gitea secret.

What the deployment command does

The workflow runs:

mkdir -p /deploy/websites/blog-src
rsync -r --delete dist/ /deploy/websites/blog-src/

The trailing slash on dist/ means "copy the contents of dist." Without it, the destination could contain an unwanted nested dist directory.

--delete makes the production directory exactly mirror the current build. When a route or asset is removed from the repository, its old generated file is also removed from production.

The destination must contain only generated website output. Do not place uploads, databases, or manually maintained files there because --delete will remove anything not present in dist/.

Runner requirements

The Gitea runner must:

  • provide the alpine-rsync label;
  • use an image containing Node.js 22.12 or newer, npm, Bash, and rsync;
  • mount /home/cab/docker/websites at /deploy/websites;
  • have write permission for /deploy/websites/blog-src;
  • have network access to Gitea and the npm registry.

The web server must read /home/cab/docker/websites/blog-src as its document root, either directly or through its own read-only bind mount.

Manual deployment

Normal deployments should always go through a pull request and Gitea Actions. For troubleshooting on the VPS, the equivalent operations are:

npm ci
npm run build
mkdir -p /home/cab/docker/websites/blog-src
rsync -r --delete dist/ /home/cab/docker/websites/blog-src/

Only run this from a checked-out copy of the repository on the VPS. A later successful Actions run will replace the destination with the build from main.

Troubleshooting

A post does not appear

Check:

  • the file is inside src/content/posts/;
  • draft is false;
  • the date and other frontmatter fields are valid;
  • the build completed successfully;
  • the pull request was merged into main;
  • the deployment job succeeded.

The build reports a content error

Run:

npm run check

Compare the post frontmatter with posts/template.md. Common causes include:

  • a missing required field;
  • an unquoted or malformed date;
  • uppercase letters, spaces, or repeated separators in the slug;
  • a non-boolean value for draft;
  • invalid YAML punctuation.

The post is in the wrong homepage position

The homepage sorts the date field newest first. Correct the frontmatter date; do not manually reorder files or rename them to control ordering.

The URL is wrong or returns 404

Confirm the slug and look for:

dist/posts/<slug>/index.html

If the slug changed after publication, the old URL is not redirected automatically.

A Gitea job never starts

Confirm:

  • Gitea Actions is enabled;
  • the runner is online;
  • the runner advertises the alpine-rsync label;
  • the workflow exists on main;
  • the event is a push to main.

Deployment fails with Permission denied

The job container's user cannot write through the bind mount. On the VPS, inspect the destination numerically:

ls -ldn /home/cab/docker/websites
ls -ldn /home/cab/docker/websites/blog-src

Set ownership or an ACL deliberately for the runner's job user. Do not solve the problem by making the directory world-writable.

Deployment succeeds but the site is stale

Check:

  • the Actions log shows the expected merged commit;
  • /home/cab/docker/websites/blog-src/index.html has a current timestamp;
  • the web server mounts that exact directory;
  • a reverse proxy or browser cache is not serving an older response.

Files disappear from the deployment directory

This is expected for files that are not in dist/ because deployment uses rsync --delete. Keep all public static files in assets/ so Astro includes them in every build.

Dependencies differ locally and in CI

Use:

rm -rf node_modules
npm ci

Commit package-lock.json whenever dependencies are intentionally changed.

Production safety

  • Protect main because every push to it deploys immediately.
  • Merge reviewed pull requests rather than committing posts directly to main.
  • Do not expose this runner to untrusted repositories. Access to the Docker socket and deployment bind mount is highly privileged.
  • Keep each repository in a separate deployment directory.
  • Keep generated production output separate from persistent application data.
  • Inspect failed or unexpected jobs in Gitea's Actions page before retrying.
S
Description
No description provided
Readme 6.2 MiB
Languages
Astro 50.6%
CSS 40.2%
TypeScript 7%
JavaScript 2.2%