Skip to content

shortstack/ghosted

Repository files navigation

πŸ‘» Ghosted

Ghosted β€” a self-hosted, terminal-themed static blog

Own your blog. Ghosted is a self-hosted static blog you run on your own AWS account β€” terminal-themed, Markdown posts as plain .txt files, no database, no monthly SaaS bill. A setup wizard provisions everything; GitHub Actions deploys on every push; you can even publish a post by opening a GitHub issue.

posts/*.txt ──▢ build.py ──▢ site/ ──▢ S3 ──▢ CloudFront (HTTPS) ──▢ your readers
      β–²                                  β–²
  write Markdown                   GitHub Actions deploys on push
  (or a GitHub issue)              (OIDC β€” no stored AWS keys)
  • πŸ—„οΈ Static β€” S3 + CloudFront. Pennies a month, fast everywhere, nothing to patch.
  • πŸ”’ HTTPS, no certificate fees β€” automatic on the CloudFront URL; for a custom domain the wizard provisions an ACM public certificate (AWS issues and auto-renews these at no charge) and wires it up. (You still pay normal CloudFront/S3 usage β€” see Cost.)
  • ✍️ Plain-text posts β€” one .txt per post (YAML front matter + Markdown). Favorites, tags, pages, RSS, client-side search.
  • πŸ€– Self-deploying β€” push to main and a GitHub Action builds + syncs + invalidates the CDN. Auth via OIDC (no AWS keys stored in GitHub).
  • πŸ™ Publish by issue β€” open a "New blog post" issue, fill the form, add the publish label.
  • πŸ§™ One-command setup β€” python setup.py creates the buckets, CDN, and deploy role for you, then writes your config.
  • 🎨 Terminal/neon theme β€” distinctive out of the box, easy to recolor.

Quickstart: zero β†’ live

You need: an AWS account, a GitHub account, and Python 3.11+.

1. Get your own copy

Click β€œUse this template” β†’ Create a new repository (or fork/clone). All your content and config live in your repo.

git clone https://github.com/<you>/<your-repo>.git
cd <your-repo>
python3 -m venv .venv
.venv/bin/pip install -r requirements-dev.txt

2. Give the wizard AWS credentials

Create one IAM user with an access key:

  1. AWS Console β†’ IAM β†’ Users β†’ Create user (e.g. ghosted-setup).
  2. Attach a policy. Easiest is the AWS-managed AdministratorAccess (you're setting up your own account). For a tighter scope, use the policy in infra/iam-setup-policy.json instead.
  3. Create access key β†’ Command Line Interface (CLI) β†’ copy the key id + secret.
  4. Configure the CLI:
    aws configure          # paste the key id + secret; region e.g. us-east-1
    (Install the AWS CLI first if needed: https://aws.amazon.com/cli/.)

The access key is only used locally to run the wizard. Your GitHub Actions deploys use a short-lived OIDC role the wizard creates β€” no keys are ever stored in GitHub.

3. Run the wizard

.venv/bin/python setup.py          # add --plan to preview without creating anything

It asks for a few details β€” site title, description, author, header prompt + footer text, AWS region, two globally-unique S3 bucket names, and your GitHub owner/repo (each with a sensible default) β€” then provisions:

  • two private S3 buckets (site + images),
  • a CloudFront distribution (HTTPS) with a clean-URL function,
  • a GitHub OIDC deploy role scoped to your repo,

and writes everything into config.toml. Re-running is safe (it's idempotent).

4. Go live

git add config.toml && git commit -m "configure" && git push

The deploy workflow runs (tests β†’ build β†’ S3 sync β†’ CloudFront invalidate) and your blog is live at the https://<id>.cloudfront.net URL the wizard printed. The first CDN deploy takes ~10–15 minutes to finish propagating.

That's it. πŸŽ‰


Writing posts

Locally

Create posts/YYYY-MM-DD-your-slug.txt:

---
title: My First Post
date: '2026-06-23T15:00:00Z'
slug: my-first-post
tags: [life, update]
feature_image: ''
format: markdown
favorite: false
---
Write **Markdown** here. Images go in `content/images/...` and are referenced
root-relative: `![alt](/content/images/2026/06/pic.jpg)`.

Then:

.venv/bin/python publish.py        # build + upload + invalidate
  • favorite: true flags a post with a β™₯ and lists it at /favorites/.
  • tags: [a, b] builds /tag/a/ pages.
  • Pages live in pages/*.txt (set nav: to add a nav link; parent: to nest one under another).

By GitHub issue

Issues β†’ New issue β†’ β€œNew blog post”, fill in title/tags/body (drag images right in), submit, then add the publish label. A workflow turns it into a post, deploys, comments the live URL, and closes the issue. Only the configured repo owner can trigger it.


Customizing

  • Site name / author / footer / header prompt: config.toml (top-level fields).
  • Theme colors & fonts: static/style.css (the :root CSS variables at the top).
  • Templates: templates/*.html (Jinja2).
  • Posts per page / RSS count: config.toml.

Custom domain (optional)

Run the wizard and answer the Custom domain prompt (e.g. example.com or blog.example.com). Ghosted requests an ACM cert (us-east-1) and configures HTTPS for you.

You must already own the domain. For the automatic path, its hosted zone must already exist in this AWS account's Route 53 β€” the wizard does not register domains or create hosted zones. If your DNS lives elsewhere, use the manual path below.

  • DNS in Route 53? The wizard creates the validation records, waits for the cert, attaches it to your distribution, and adds the ALIAS records β€” fully automatic. Apex domains also get www β†’ apex.
  • DNS elsewhere (Cloudflare, Namecheap, …)? The wizard prints the exact records to add at your host, then run python setup.py --attach-domain once they've propagated; it finishes the setup and prints the final record to point your domain at CloudFront.

Already live and want to add a domain later? Just run python setup.py --domain example.com.


Architecture

Piece Role
S3 (site bucket) holds the generated HTML; private, served only via CloudFront (OAC)
S3 (images bucket) holds content/images/**; served at /content/images/*
CloudFront HTTPS + global CDN; a tiny function rewrites clean URLs to index.html
GitHub Actions + OIDC role builds and deploys on push; assumes a short-lived role (no stored keys)
ghosted/ package the generator: postfile, convert, render, site, awsbuild, provision

Cost: a low-traffic blog typically runs cents to a couple dollars a month (S3 storage + CloudFront egress; both have generous free tiers).


Teardown

.venv/bin/python teardown.py        # empties + deletes the buckets and the deploy role

It prompts you to type yes before deleting anything. CloudFront requires a disable-then-delete cycle, so the script prints guidance to remove the distribution/function in the console.


How it's built

Python (Jinja2 Β· python-markdown Β· markdownify Β· python-frontmatter Β· BeautifulSoup Β· feedgen) β†’ static HTML β†’ S3 + CloudFront. boto3 for provisioning. Tested with pytest + moto. See CONTRIBUTING.md.

License

MIT β€” use it for anything.

About

πŸ‘» Own your blog β€” a self-hosted, terminal-themed static blog on S3 + CloudFront. Plain-text Markdown posts, one-command AWS setup, deploys from GitHub (even by issue).

Topics

Resources

License

Contributing

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors