Publishing a blog post can feel intimidating when Markdown, Git, GitHub Actions, and a content management system are all involved. The good news is that you do not need to understand every internal detail before you begin. This guide walks through the complete Apify blog workflow, one safe step at a time.
It is also delightfully self-referential: this article was created to test the same workflow it explains.
What this workflow does
Apify blog posts are stored as MDX files in Git and synchronized to Ghost CMS. Git gives every change a visible history and a review process; Ghost turns the approved content into the published web page.
There are two separate gates:
- Adding the
sync-to-ghostlabel to a pull request creates or updates a draft preview in Ghost. - Merging the pull request publishes the post—or schedules it when its publication date is in the future.
That distinction is the most important safety rule in the entire process. A preview cannot accidentally publish a new article. Changing status: draft to status: published by hand is not how you publish; merging the approved pull request is.
| Action | What happens in Ghost | Is it public? |
|---|---|---|
Run pnpm sync:dry |
A payload is printed locally | No |
Add the sync-to-ghost PR label |
A draft preview is synchronized | No |
| Merge the pull request | The post is published or scheduled | Yes, immediately or on the scheduled date |
Before you start
You need:
- access to this GitHub repository;
- Git installed;
- Node.js in the version specified by the repository's
.nvmrcfile; - pnpm 10.10.0; and
- a text editor, such as Visual Studio Code.
You do not need Ghost credentials for local validation. Real synchronization is performed by GitHub Actions with repository secrets, never by a writer's computer.
After cloning the repository, install its dependencies:
pnpm install --frozen-lockfile
Create a branch for the article. A short, descriptive branch name is easiest for teammates to recognize:
git switch -c article/publish-apify-blog-from-github
If you are already working on the correct branch, do not create another one.
Step 1: Create the post folder
Every new article gets a folder named after its URL slug and an index.mdx file inside it:
content/blog/
└── publish-apify-blog-from-github/
├── index.mdx
└── github-to-ghost-workflow.png
The slug should use lowercase letters and hyphens. Avoid spaces, underscores, uppercase letters, and punctuation. Once an article is live, changing its folder name can create a second Ghost post instead of renaming the original, so choose carefully.
Step 2: Add frontmatter
Frontmatter is the metadata block between the two --- lines at the beginning of index.mdx. Here is a small but complete example:
---
title: "How to publish an Apify blog post from GitHub"
slug: publish-apify-blog-from-github
author: apify
tags:
- tutorial
- programming
status: draft
featureImage: ./github-to-ghost-workflow.png
excerpt: "A short summary of the article, no longer than 160 characters."
---
The required fields are title, author (or authors), tags, and excerpt. The explicit slug is optional because the folder name is used by default, but if you include it, it must match the folder exactly.
Use only author slugs and tag slugs that already exist in Ghost. The repository's publishing skill keeps the current tag list in .github/skills/publish-blog-post/references/tags.md, and the sync tool warns about unknown authors or tags.
Leave status as draft, or omit it. Also omit publishedAt unless the article needs a deliberate date:
- no
publishedAt: continuous integration stamps the merge date; - a past or present date: the merged article publishes with that date;
- a future date: Ghost schedules the article for midnight UTC on that date.
Step 3: Write the article body
Write standard Markdown below the frontmatter. The title is already supplied by title, so do not add another level-one heading (# Heading) to the body. Open with a paragraph, then organize the article with level-two and level-three headings.
Label fenced code blocks so syntax highlighting works:
```javascript
console.log('Hello from a properly labeled code block');
```
The repository also supports a few special directives:
::signup-ctainserts a fixed signup call to action;::contact-sales-ctainserts a fixed contact-sales call to action;:::faq{question="..."}creates a frequently asked question;:::comparison-tablegives its Markdown table the blog theme's comparison styling; and- a bare YouTube URL on its own line becomes a Ghost video embed.
Snippet copy is fixed globally. Attributes such as ::signup-cta{text="Different text"} are ignored, so create or request a new reusable snippet when genuinely different copy is needed.
Step 4: Add images
Place article images beside index.mdx and reference them with relative paths:

Use the same form in frontmatter for the feature image:
featureImage: ./github-to-ghost-workflow.png
During a real CI sync, local images are resized to a maximum width of 1,600 pixels, converted to WebP, and uploaded to Ghost. Uploads are cached by file hash in content/blog/.image-cache.json, but that cache is only saved back to main once the pull request merges. Until then, each preview sync re-uploads the same image—harmless, since the duplicates are just orphaned storage in Ghost, and it stops as soon as the post merges.
Good alt text describes the useful information in an image. It should not begin with “image of,” and it should not be used as a place to stuff keywords.
Step 5: Validate locally
Run the repository checks before opening a pull request:
pnpm lint
pnpm format:check
pnpm sync:dry --file content/blog/publish-apify-blog-from-github/index.mdx
The dry run is safe. It parses the frontmatter, converts Markdown to HTML, resolves local image references without uploading them, and prints the exact Ghost post payload. Its last line should be:
Dry run complete — nothing was sent to Ghost.
Read warnings as well as errors. A warning may reveal an unknown author, an unknown tag, an unsupported snippet, ignored snippet attributes, or SEO text likely to be truncated. A successful command with a warning still deserves attention.
Never run pnpm sync locally. A real sync requires CI-only Ghost credentials and bypasses the repository's review boundary.
Step 6: Review the Git diff
Before committing, check exactly what changed:
git status --short
git add content/blog/publish-apify-blog-from-github/
git diff --cached -- content/blog/publish-apify-blog-from-github/
Staging first matters because a normal git diff does not display brand-new untracked files. Look for accidental files, secrets, incorrect paths, spelling mistakes in metadata, and unfinished notes. If something should not be staged, remove it from the proposed commit without deleting the file:
git restore --staged <file>
When the staged diff is correct, commit and push it:
git commit -m "content: add GitHub to Ghost publishing guide"
git push -u origin HEAD
Step 7: Open the pull request
Open a pull request against main and describe what the article covers. Keep it open—or make it a draft pull request—until the article is ready to become public.
Add the preview label from the GitHub interface or with the GitHub CLI:
gh pr edit <pull-request-number> --add-label sync-to-ghost
The label starts the preview workflow. It detects changed blog files, installs dependencies, validates and converts the post, uploads new images, and creates or updates a Ghost draft. The pull request receives a summary with the result and preview link.
If you push another commit while the label remains attached, the workflow runs again. Draft previews can therefore follow the review instead of becoming stale.
There is one exception: when editing a post that is already published or scheduled in Ghost, the PR workflow skips it so an unapproved edit cannot affect the live post. Use the local dry-run output for review; the update reaches Ghost only after merge.
Step 8: Review the Ghost preview
Open the preview link and check the rendered result, not only the source file. Confirm:
- the title, excerpt, author, and tags are correct;
- headings form a sensible hierarchy;
- links open the intended pages;
- code blocks, tables, FAQs, and CTAs render correctly;
- every image loads, has appropriate alt text, and looks sharp; and
- mobile-width content does not overflow.
Fix problems in the MDX source, commit, and push again. Do not make the canonical correction only in Ghost Admin: this repository is the source of truth, and there is currently no automatic two-way synchronization from Ghost back to Git.
Step 9: Merge to publish
Once reviewers approve the article, a human merges the pull request. That merge is the publication decision.
The publish workflow then:
- detects every changed blog file in the merge;
- changes its local frontmatter status to
published; - synchronizes it to Ghost without the preview-only draft override;
- commits the confirmed status changes back to
main; and - records a readable result in the GitHub Actions run summary.
If publishedAt is sufficiently far in the future, the workflow sends scheduled to Ghost instead. The repository still records status: published, meaning the publication decision has been made even though Ghost is waiting for the scheduled time.
Step 10: Verify the result
Do not assume that a merged pull request guarantees a successful publication. Open the Publish blog posts to Ghost workflow run and check its summary. Then verify the live URL—or the scheduled state and time in Ghost.
When several files are involved and one fails, the workflow commits status: published only for files Ghost confirmed. Failed files remain draft on main, and the workflow ends in a failed state so the problem stays visible. Retry one failed file with the publish workflow's manual file input after correcting the cause.
Common mistakes and recovery
| Problem | What to do |
|---|---|
| Required frontmatter is missing | Add the field shown in the dry-run error and run the checks again |
| The slug does not match the folder | Make them identical before the first publication |
| An image cannot be found | Check the exact filename, extension, letter case, and relative path |
| An author or tag is unknown | Replace it with an existing Ghost slug; do not invent taxonomy casually |
| The PR preview changed nothing | Confirm the label is present; an already-live post is intentionally skipped |
| The wrong future date was set | Correct publishedAt before merge and remember that a bare date means midnight UTC |
| A live post needs a new slug | Coordinate the change in Ghost Admin and remove the unwanted duplicate |
| A post must be deleted or unpublished | Do it manually in Ghost Admin; deletion is not part of this sync pipeline |
The complete beginner's checklist
- Create
content/blog/{slug}/index.mdx. - Add valid title, author, tags, and excerpt frontmatter.
- Leave
statusasdraftor omit it. - Add local images with relative paths.
- Run lint, format check, and the dry-run sync.
- Read and resolve warnings, not only errors.
- Review the Git diff.
- Commit, push, and open a pull request.
- Add the
sync-to-ghostlabel. - Review the Ghost draft on desktop and mobile.
- Keep fixing the MDX file until reviewers approve it.
- Merge only when the post may publish or enter its scheduled state.
- Check the publish workflow and final Ghost result.
Full-stack platform for web scraping
Can adding the sync-to-ghost label publish my article?
No. The pull-request workflow forces new posts to remain drafts. Merging the approved pull request is the publication decision.
Do I need Ghost credentials on my computer?
No. Local dry runs need no credentials, and GitHub Actions holds the secrets used for preview and publication syncs.
How do I schedule an article?
Set publishedAt to a future date in YYYY-MM-DD format and merge when the article is approved. Ghost will hold it until midnight UTC on that date.
Where should I fix an error found in the Ghost preview?
Fix it in the MDX file and push the change to the pull request. The Git repository is the source of truth; edits made only in Ghost do not flow back automatically.
Can I edit a post after it's already live?
Yes. Open a normal pull request against the file, exactly as you did the first time. The PR-time sync won't touch the live post while your changes are in review — it only takes effect once the pull request merges.