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
.txtper post (YAML front matter + Markdown). Favorites, tags, pages, RSS, client-side search. - π€ Self-deploying β push to
mainand 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
publishlabel. - π§ One-command setup β
python setup.pycreates the buckets, CDN, and deploy role for you, then writes your config. - π¨ Terminal/neon theme β distinctive out of the box, easy to recolor.
You need: an AWS account, a GitHub account, and Python 3.11+.
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.txtCreate one IAM user with an access key:
- AWS Console β IAM β Users β Create user (e.g.
ghosted-setup). - 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.jsoninstead. - Create access key β Command Line Interface (CLI) β copy the key id + secret.
- Configure the CLI:
(Install the AWS CLI first if needed: https://aws.amazon.com/cli/.)
aws configure # paste the key id + secret; region e.g. us-east-1
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.
.venv/bin/python setup.py # add --plan to preview without creating anythingIt 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).
git add config.toml && git commit -m "configure" && git pushThe 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. π
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: ``.
Then:
.venv/bin/python publish.py # build + upload + invalidatefavorite: trueflags a post with a β₯ and lists it at/favorites/.tags: [a, b]builds/tag/a/pages.- Pages live in
pages/*.txt(setnav:to add a nav link;parent:to nest one under another).
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.
- Site name / author / footer / header prompt:
config.toml(top-level fields). - Theme colors & fonts:
static/style.css(the:rootCSS variables at the top). - Templates:
templates/*.html(Jinja2). - Posts per page / RSS count:
config.toml.
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-domainonce 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.
| 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).
.venv/bin/python teardown.py # empties + deletes the buckets and the deploy roleIt 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.
Python (Jinja2 Β· python-markdown Β· markdownify Β· python-frontmatter Β· BeautifulSoup Β· feedgen) β static HTML β S3 + CloudFront. boto3 for provisioning. Tested with pytest + moto. See CONTRIBUTING.md.
MIT β use it for anything.
