I write this blog in Markdown. Each post is a file in a GitHub repository, and pushing the file is what publishes it. WordPress still serves the site, but I do not write in its editor.
This is the third post about how this blog is built. The earlier ones cover running WordPress at /blog on a Laravel site and sharing posts on LinkedIn.
How it works
- I add or edit
posts/some-slug.mdand push tomain. - A GitHub Actions workflow logs in to the server over SSH, pulls the repo and runs one WP-CLI command.
- That command, which lives in my site plugin, converts each changed file to HTML and creates or updates the matching post.
A post file looks like this:
---
title: My first post
date: 2026-10-20 09:00
excerpt: One or two sentences shown in listings and on LinkedIn.
categories: [WordPress]
tags: [composer, deployment]
---
The post body in Markdown.
The rules I settled on
- The file name is the post’s URL.
posts/my-first-post.mdbecomes/blog/my-first-post/. - The Markdown file is the source of truth. A change made in wp-admin is overwritten the next time the file changes.
- A future
dateschedules the post. Pushing a file never has to mean publishing it now. I write several posts in one sitting and give each a date. - A past or missing
datepublishes straight away. This is also how a typo fix reaches a live post. status: draftholds a post back whatever its date.- Deleting a file moves the post to the Trash. Nothing is deleted for good.
- Unchanged files are skipped, so a push only touches the posts I edited.
Public or private repo
I went with a private repo. In a public one, every unfinished draft and every old revision is visible from the moment it is pushed. A private repo can be made public later; the reverse does not un-publish anything.
I also kept posts in their own repo, away from the blog’s code. The code repo has a release branch and runs Composer on every deploy, which is too much ceremony for fixing a typo.
The workflow
name: Publish posts
on:
push:
branches: [ "main" ]
paths: [ "posts/**" ]
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Sync posts to WordPress
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USERNAME }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
script: |
set -e
cd ~/blog_posts
git pull origin main
wp --path=~/blog_wp myblog sync-posts ~/blog_posts/posts
The server has its own clone of the posts repo and pulls it with a read-only deploy key.
The sync command
The command is a plain function registered with WP_CLI::add_command(). These are the parts worth explaining.
Front matter. WordPress has no YAML parser and I did not want one for six keys. A few lines of regex read key: value pairs, and turn [a, b] into a list:
if ( preg_match( '/\A---\R(.*?)\R---(?:\R(.*))?\z/s', $text, $parts ) ) {
$text = isset( $parts[2] ) ? $parts[2] : '';
foreach ( preg_split( '/\R/', $parts[1] ) as $line ) {
if ( preg_match( '/^([a-z_]+):\s*(.*)$/', $line, $pair ) ) {
$meta[ $pair[1] ] = trim( $pair[2] ); // lists are split on commas here
}
}
}
Markdown to HTML. I use Parsedown, installed with Composer:
$html = ( new Parsedown() )->text( $markdown );
Knowing which post belongs to which file. Each post stores its file name and an MD5 hash of the file in post meta. The file name finds the post on the next run. The hash tells the command whether anything changed:
if ( $post && get_post_meta( $post->ID, '_myblog_markdown_hash', true ) === md5( $contents ) ) {
continue;
}
Saving the post. The content is wrapped in a single Custom HTML block:
$postarr = array(
'post_type' => 'post',
'post_name' => $slug,
'post_title' => $meta['title'],
'post_excerpt' => $meta['excerpt'],
'post_content' => "<!-- wp:html -->\n" . $html . "\n<!-- /wp:html -->",
'post_status' => 'draft' === $meta['status'] ? 'draft' : 'publish',
'tags_input' => $meta['tags'],
);
$saved = $post
? wp_update_post( wp_slash( $postarr + array( 'ID' => $post->ID ) ), true )
: wp_insert_post( wp_slash( $postarr ), true );
Scheduling needs no code of its own. When a post is saved as publish with a date in the future, WordPress stores it as scheduled and publishes it at that time.
Things that caught me
- Wrap the HTML in a Custom HTML block. Without block markup, WordPress runs
wpautopover the content when displaying it, and every line break inside a paragraph becomes a<br>. - Call
wp_slash()on the data.wp_insert_post()expects slashed input and strips one level of backslashes. Without it, every backslash in a code sample disappears. - Use
wp_update_post()for existing posts. My first version calledwp_insert_post()with an ID for updates. That does not merge with the existing post, so editing a live post reset its date to today. I caught it in local testing. - Scheduled posts need a real cron job. WordPress’s built-in cron only runs when someone visits the site. I added a server cron entry that runs
wp cron event run --due-nowevery five minutes. - Check the site timezone. Mine was UTC, so
09:00in a file would have meant three in the afternoon here. It is set to my own timezone now. - Guard against an empty folder. If the command is pointed at the wrong path it would see no files and trash every post. It stops and changes nothing when it finds no Markdown files.
- Renaming a file creates a new post. The file name is the identity, so the old post is trashed and a new one appears at the new URL.
The edit link
Because each post knows its file name, the plugin adds an “Edit Markdown” link that opens the file on GitHub. It shows in the Posts list, in the admin bar when I view a post, and as a notice in the editor that says changes made there will be overwritten. Only logged-in users who can edit the post see it.
What it does not do yet
When I wrote this, images were not stored in the repo, and featured images and syntax highlighting were on the list. All three have been added since: a post’s images now live next to it in the repo, and the sync adds them to the Media Library.
The post you are reading was published this way, from a file named 03-publish-wordpress-posts-from-markdown.md. The number in front only keeps the files in order; the rest of the name is the URL.
Leave a Reply