Easy-to-use blogging platform for creators
1.0K
An easy-to-use blogging platform for creators. Write in Markdown, publish in seconds.
One Bun server does everything. It renders the public blogs, serves the JSON API, and hosts the creator panel. Published posts are real server-rendered HTML, so search engines and link previews read them without running JavaScript.
┌───────────────────────────────────┐
readers → │ Bun server │
creators → │ │
│ / rendered blogs │
│ /creator/:user/:slug │
│ /creator/:user/feed.rss|atom|json│
│ /sitemap.xml /robots.txt │
│ /media/* avatars & images │
│ │
│ /panel creator panel │
│ /api/v1/* JSON API │
│ /metrics OpenMetrics │
└─────────────────┬─────────────────┘
▼
SQLite · PostgreSQL · MySQL · MariaDB
local disk or any S3-compatible store
bun install
cp .env.example .env
bun run keygen >> .env # generates ENCRYPTION_KEY and ADMIN_TOKEN
bun run dev
Then open http://localhost:3000/panel and create the first account. Your blog is live at http://localhost:3000/creator/<username>.
bun run dev builds the panel, starts the server under bun --watch, and rebuilds the panel when its sources change. One command, one Ctrl-C. Migrations apply automatically at startup.
src/
server/ the Bun server
routes/ API, rendered pages, panel hosting, moderation
ssr/ page templates, markdown, feeds, stylesheet
db/ schema, migrations, repositories
auth/ sessions and two-factor
middleware/ auth, CSRF, cache, metrics, envelope
lib/ config, crypto, storage, backups, validation, errors
panel/ the creator panel (client-side app)
views/ sign-in, posts, editor, images, analytics, settings, moderation, backups
shared/ values both sides must agree on
scripts/ dev runner, build, admin promotion
tests/
public/panel/ built panel bundle (generated)
src/shared/ is why this is one project rather than two. Error codes, categories, languages, themes and social platforms live there in a single copy, so the panel can never offer something the server rejects.
Bloggy is built so a blog is fully indexable without any work from the creator.
sitemap.xml and robots.txt are generated and kept current. Drafts and suspended accounts never appear in either.<link rel="alternate"> and by a visible feed icon, since browsers stopped offering one of their own.noindex instead, because there is no limit to how many of them exist and they mostly repeat each other.rel="next", which JavaScript upgrades into infinite scrolling. With scripting disabled the same link still works, so no post is unreachable.Every setting lives in .env. See .env.example, which documents each one. The essentials:
| Variable | Default | Notes |
|---|---|---|
PORT | 3000 | |
BIND_ADDRESS | 0.0.0.0 | Not HOSTNAME, see below |
DOMAIN | http://localhost:3000 | Public origin, and decides the cookie's Secure flag |
DATABASE_URL | sqlite://./data/bloggy.sqlite | sqlite://, postgres://, mysql://, mariadb:// |
ENCRYPTION_KEY | none | Required. Encrypts TOTP secrets at rest |
ADMIN_TOKEN | none | Guards /metrics and the maintenance endpoints |
API_ORIGINS | (empty) | CORS for external clients. Empty disables it entirely |
TRUST_PROXY | direct | Set to cloudflare behind Cloudflare |
BIND_ADDRESS, notHOSTNAME. Bash and every Docker container already exportHOSTNAME, and a real environment variable takes precedence over.envin Bun. Using that name would silently bind the server to the machine's hostname instead of all interfaces.
The same schema runs on all four supported databases through Bun's native SQL client:
DATABASE_URL=sqlite://./data/bloggy.sqlite
DATABASE_URL=postgres://user:password@localhost:5432/bloggy
DATABASE_URL=mysql://user:password@localhost:3306/bloggy
DATABASE_URL=mariadb://user:password@localhost:3306/bloggy
Three rules keep one schema portable, worth knowing before adding a migration:
VARCHAR(32) rather than native date types, because each driver handles dates differently, while text comes back exactly as it was stored and still sorts correctly.VARCHAR(n), never TEXT, because MySQL cannot index a TEXT column with no length limit.CREATE TABLE for MySQL and MariaDB, which reject CREATE INDEX IF NOT EXISTS, and as separate statements elsewhere.Migrations in src/server/db/schema.ts are only ever added to. Never edit one that has already run somewhere, write a new one instead.
Avatars and post images go to the local filesystem by default. Set STORAGE_DRIVER=s3 and the S3_* variables to use any S3-compatible service (R2, MinIO, Backblaze, AWS) through Bun's native S3 client. Set CDN_URL to serve media from a CDN instead of from this server.
MAX_AVATAR_SIZE and MAX_IMAGE_SIZE bound a single upload. MAX_ACCOUNT_STORAGE bounds the total per account, so one creator cannot fill the volume a megabyte at a time. Set it to 0 to allow unlimited storage.
Creators sign in against an argon2id hash (Bun.password). A session then travels two ways:
Secure; HttpOnly; SameSite=Lax cookie. JavaScript cannot read it, so an XSS bug in the panel cannot steal the session. This is only possible because the panel is served from the same origin as the API.Authorization: Bearer <token>, returned by POST /api/v1/auth/login. A header the client sets on purpose always wins over a cookie the browser attaches on its own.Writes that rely on the cookie also pass a CSRF check. SameSite=Lax already stops browsers attaching the cookie to a cross-site POST, and the server independently requires such requests to declare an Origin it owns. Bearer requests are exempt, because a cross-origin page cannot set that header without CORS allowing it.
Two-factor authentication is TOTP, with ten single-use backup codes. Secrets and codes are encrypted at rest with XChaCha20. A backup code is consumed the first time it is used.
Every JSON endpoint returns the same envelope:
{ "error": 0, "info": "Success", "data": {} }
error is 0 on success and a numeric code otherwise. The codes are a stable public contract, defined once in src/shared/errors.ts and used by both sides. Values are never reused.
| Method | Path | Purpose |
|---|---|---|
POST | /api/v1/auth/register | Create an account |
POST | /api/v1/auth/login | Sign in and set the session cookie |
POST | /api/v1/auth/logout | Revoke the current session |
GET | /api/v1/auth/me | Current creator |
POST | /api/v1/auth/password | Change password |
GET | /api/v1/auth/sessions | List active sessions |
DELETE | /api/v1/auth/sessions[/:id] | Revoke one, or all others |
POST | /api/v1/auth/2fa/begin|confirm|disable | TOTP enrolment |
POST | /api/v1/auth/2fa/backup-codes | Regenerate backup codes |
GET | /api/v1/posts | Your posts, drafts included |
POST | /api/v1/posts | Create a draft or publish a post |
GET | /api/v1/posts/:slug | One of your posts, with markdown |
PUT | /api/v1/posts/:slug | Edit, publish or unpublish |
DELETE | /api/v1/posts/:slug | Delete a post |
POST | /api/v1/preview | Render markdown for the editor |
GET | /api/v1/media | Your images, with storage used |
PUT | /api/v1/media | Upload an image (raw body) |
DELETE | /api/v1/media/:id | Delete an image |
POST | /api/v1/creators/me/settings|social | Update blog settings or links |
PUT | /api/v1/creators/me/avatar | Upload an avatar (raw body) |
DELETE | /api/v1/creators/me | Delete the account and all data |
GET | /api/v1/analytics | Gateway traffic, when configured |
GET | /api/v1/analytics/pages | Pages you can break out singly |
GET | /api/v1/creators[/:username] | Public creator directory |
GET | /api/v1/config | Instance limits, read by the panel |
Create and edit accept "status": "draft" | "published", defaulting to published. See Drafts.
A creator page lists the newest posts twelve at a time. The next page is a plain link, which JavaScript turns into infinite scrolling when it is available.
Search filters a creator's posts by title, tag or keywords. It is an ordinary GET form, so it works without JavaScript and every result has a shareable URL.
Tags filter the same listing. Every post card links to its own tag, which gives readers a way to find more on the same topic and search engines something to index.
A post is either a draft or published. Drafts are filtered out in SQL on every public surface, covering the blog, feeds, the sitemap and the public JSON API, so a draft URL 404s exactly as a missing post does and its existence is not observable from outside.
Draft validation is deliberately loose. A draft needs only a title, because refusing to save unfinished work would defeat the point. The full rules (150+ words, a description, a cover image, keywords) apply the moment you publish.
published_at is set the first time a post goes public and never moves afterwards, so correcting a typo does not push the post back to the top of every feed. Feeds and structured data date posts by published_at, while created_at remains the row's creation date.
Authors preview their own posts at /preview/:slug. That is a separate path from the public URL on purpose, because public pages are cached and a response that varied by viewer could be stored and then served to everyone. The preview route requires a session, is never cached, and is marked noindex.
An administrator is an ordinary account carrying a flag, so moderation uses the same sign-in, two-factor and CSRF protection as everything else rather than a shared secret pasted into a browser.
bun run promote <username>
bun run demote <username>
Both need shell access to the server, which is the right bar for handing out moderation powers. Demoting the last administrator is refused, so an instance cannot lock itself out.
The Moderation screen lists every account with its post count, storage used, registration date and last activity, sortable by any of them, which is how an account consuming far more than the rest is found. From there an administrator can:
Suspension and deletion refuse to act on another administrator until that account is demoted, so one compromised session cannot quietly remove the others. Every action writes an audit line.
Maintenance endpoints guarded by ADMIN_TOKEN as a bearer token remain available for scripts: GET /api/v1/admin/stats, POST /api/v1/admin/cache/purge, POST /api/v1/admin/sessions/prune, and GET /metrics.
SQLite instances can snapshot themselves to object storage on a schedule.
BACKUP_ENABLED=true
BACKUP_INTERVAL=21600 # seconds, so this is every six hours
BACKUP_KEEP=7 # oldest snapshots are pruned after each run
BACKUP_S3_BUCKET=...
BACKUP_S3_ACCESS_KEY_ID=...
BACKUP_S3_SECRET_ACCESS_KEY=...
A snapshot contains every password hash and email address. Give it a private bucket of its own, never the media bucket, which is public whenever
CDN_URLis set.
Snapshots are taken with VACUUM INTO rather than by copying the file. The database runs in WAL mode, where recent commits live in a side file until they are checkpointed, so copying bloggy.sqlite would quietly produce a database missing its newest writes.
The Backups screen lists what is stored and can take one on demand, download it, delete it, or restore it. Restoring verifies the snapshot opens as a Bloggy database, keeps the current one alongside it, then replaces it and stops the server so whatever runs it starts again on the restored copy. Docker's restart: unless-stopped does this for you.
Uploaded images are not part of a snapshot, since they already live in object storage with its own durability. Enabling versioning on the media bucket covers them.
PostgreSQL, MySQL and MariaDB instances should use their own backup tooling, and the screen says so rather than offering a button that cannot work.
The Analytics page appears only when Bloggy runs behind a BurrowGate gateway with a read-only monitoring token:
BURROWGATE_URL=https://gateway.example.com
BURROWGATE_TOKEN=bgro_... # "monitoring" scope, read-only
BURROWGATE_SITE_ID=... # this instance's site in BurrowGate
Four tabs: traffic over time, most read pages, countries (with a world map), and referrers. Pages are listed by post title with the slug beneath, not as raw paths, and a path with no matching post is shown as its slug marked No longer published rather than given an invented title. Gateway-centric views such as cache hit ratio and latency are deliberately not offered, because they describe the gateway's health rather than a blog's readership.
One page at a time. A picker narrows every tab to a single post, or to the blog's index page, which is how you find out where the readers of one particular piece came from. The picker sends a slug rather than a path. The server builds the path from the session, so a creator cannot address another creator's pages by editing the request. Selecting a page hides the Pages tab, which has nothing left to rank.
What the numbers are. Requests the gateway served, excluding anything the origin refused. That is a better place to measure from than inside Bloggy, which only sees pages it had to render, but it is still not a count of people. A page answered by the reader's own browser cache, or by a CDN in front of the gateway, never reaches BurrowGate and is not counted. Read the figures as a floor.
404s are not readership. Bloggy always passes successfulOnly, so a scan for /creator/you/wp-admin and friends, which lands inside your path scope and would otherwise arrive complete with countries and referrers, is dropped before it reaches a chart. The flip side is that genuine broken links do not appear either.
Scoping is enforced on Bloggy's server. It fixes siteId from configuration and derives the path scope from the signed-in creator's session, so a creator sees only their own pages and cannot widen that by editing the request. The prefix carries no trailing slash, so the blog's index page counts alongside its posts, and the gateway matches the path itself or anything beneath it, which excludes a sibling username sharing a prefix. The token is used server-side only and never reaches the browser. Only a fixed set of views is forwarded, so host telemetry such as CPU and memory is not reachable through Bloggy.
Bloggy refuses rather than guesses. BurrowGate reports back which pages it measured, using pathPrefix for a whole blog and path for a single page, and a reply that does not match what was asked for is thrown away with an error asking the operator to upgrade. A gateway predating these parameters would otherwise silently answer with the whole site, showing one creator everyone else's pages.
The world map ships with the panel (/panel/assets/world.svg), so it renders without a round trip to the gateway.
Without those variables the page, its nav item and its routes do not exist at all.
Rendered pages and public API reads are cached in memory (CACHE_TTL, default 5 minutes). Repeat visitors get a small "nothing has changed" reply instead of the whole page, and once an entry ages out the old copy is still served while a fresh one is prepared, so nobody waits. Publishing, editing, unpublishing or deleting a post clears that creator's pages plus the shared landing page and sitemap, rather than the whole cache.
Nothing is cached for a request that carried credentials, whether a bearer token or the session cookie. The cookie half matters. Since the panel is served from this origin, a signed-in creator browsing their own blog sends a cookie and no Authorization header, so checking only the header would let a per-viewer response into a cache every reader shares.
Search results are never cached, because anyone can type anything into the box, and caching every phrase would let one visitor fill the store with entries nobody reads twice.
Bun.markdown with raw HTML escaping on, and the generated href and src attributes are then restricted to http, https and mailto, so neither injected tags nor javascript: URLs survive. Everything a creator supplies is HTML-escaped before it reaches a page template.noindex, excluded in robots.txt, and refuses to be framed.Nine Rabbit Company packages and nothing else:
web · web-middleware · totp · qrcode · blake2b · xchacha20 · password-entropy · password-generator · openmetrics-client
Everything else is native: Bun.password (argon2id), Bun.markdown, Bun.SQL, Bun.s3, Bun.file, Bun's bundler for the panel's TypeScript and CSS, and createImageBitmap plus a canvas for browser-side image compression.
bun run dev # build panel, run server in watch mode
bun run start # run once
bun run build # panel bundle + single-file server binary in dist/
bun run build:panel # panel bundle only
bun run migrate # apply migrations and exit
bun run keygen # print fresh secrets for .env
bun run promote # grant an account administrator rights
bun run demote # revoke them
bun run test # unit tests
bun run typecheck # tsc --noEmit
bun run format # prettier
docker compose up -d
The image builds the panel and compiles the server into a single binary, then runs it on a slim Debian base with no Bun and no node_modules. Data lives in a volume mounted at /app/data, and the health check calls /health, which makes a real database round trip rather than only proving the process is alive.
bun run build produces dist/bloggy, a standalone binary, plus public/panel/. Ship both, along with .env and a writable data/ directory.
Either way, put Bloggy behind a reverse proxy that terminates TLS, because the session cookie is only marked Secure when DOMAIN starts with https://. Set TRUST_PROXY so client IPs are read from the right header, otherwise rate limiting sees the proxy's address for every visitor.
EUPL-1.2. See LICENSE.
Content type
Image
Digest
sha256:3a09b679a…
Size
69.1 MB
Last updated
about 22 hours ago
docker pull rabbitcompany/bloggy