Self-hosting the server
The Go service behind madelog publish, in a container with Postgres. Environment, every endpoint with its request and response bodies and status codes, the signature byte strings, migrations, and the Railway setup.
The server is small on purpose. It stores published pages, keeps a history of chain roots, serves each page and its verification record, and files billing webhooks for later. It has no accounts, no sessions and no admin surface. Source: server/ in the repository, Go 1.22 or later, chi, pgx and sqlc on Postgres.
Run it with Docker
server/docker-compose.yml starts Postgres 16 and the server together:
$ cd server
$ cp .env.example .env
$ make up # docker compose up --build -d; serves on http://localhost:8080
$ curl -sS http://localhost:8080/healthz
{"status":"ok"}
$ make down # docker compose down -v: stops both and drops the volumeThe compose file gives the server DATABASE_URL=postgres://madelog:madelog@db:5432/madelog?sslmode=disable, LISTEN_ADDR=:8080, PUBLIC_BASE_URL=http://localhost:8080, an empty LEMONSQUEEZY_SECRET and LOG_FORMAT=text. Postgres waits for pg_isready before the server starts.
For development against the same database without the server container: make db-up (Postgres only, waits until it answers) then make run (reads .env when it exists, defaults the three variables above, and runs go run ./cmd/madelog-server). make test runs the handler and unit tests against an in-memory store; make test-db runs the same suite against Postgres. make sqlc regenerates internal/db after a change to db/queries or db/migrations.
Migrations are embedded in the binary and applied at startup under a Postgres advisory lock, each file in its own transaction, so there is no migration step and two instances cannot race. The schema is append-only: a change is a new db/migrations/0002_*.sql, never an edit to a shipped file. A healthy start logs connected to postgres, schema is up to date and listening.
The image is a static binary on distroless/static, with no shell inside.
Environment
| Variable | Required | Default | Meaning |
|---|---|---|---|
DATABASE_URL | yes | — | Postgres DSN for pgx |
PUBLIC_BASE_URL | yes | — | The origin the API writes into the url and verify_url it returns; a trailing slash is trimmed; must be an absolute URL with scheme and host |
LISTEN_ADDR | no | :$PORT, else :8080 | Bind address. Falls back to PORT, which platforms such as Railway set |
LEMONSQUEEZY_SECRET | no | empty | Signing secret for billing webhooks. Empty makes the webhook answer 503 rather than accept what it cannot verify |
LOG_LEVEL | no | info | debug, info, warn, error |
LOG_FORMAT | no | json | json or text |
The process refuses to start without the two required values and names the missing one in a first log line starting with config:.
Endpoints
Every /v1/ response is JSON. Errors are {"error": "<code>", "message": "<text>"}. Every response carries an X-Request-Id header you can quote in a bug report. Request bodies must be a single JSON object; a field the endpoint does not know is a 400, so a typo such as publishedTs fails loudly instead of being ignored and then failing signature verification.
POST /v1/devices
Registers an ed25519 public key. Idempotent: the same key always maps to the same id.
request {"public_key": "<64 lowercase hex characters>"}
response 200 {"device_id": "5ea56900-b0d3-4683-aa32-fe1e3eca821c"}
400 bad_request body is not one JSON object with a public_key field
400 bad_public_key "public_key must be a hex-encoded 32-byte ed25519 public key"
413 too_large body over 4 KiBPOST /v1/pages
Stores a signed page.
request {"device_id", "slug"?, "html", "root", "first_ts", "last_ts", "published_ts", "signature"}
response 200 {"slug": "north-fable-4xt3",
"url": "<PUBLIC_BASE_URL>/p/north-fable-4xt3",
"verify_url": "<PUBLIC_BASE_URL>/v1/verify/north-fable-4xt3",
"received_at": "2026-09-18T07:53:50.471573Z"}Checks, in the order the handler makes them, and the status each failure gets:
| Status | error | When |
|---|---|---|
| 413 | too_large | The request body is over four times the html limit plus 64 KiB |
| 400 | bad_request | The body is not one JSON object, or has an unknown field, or html is empty |
| 413 | html_too_large | html is over 2 MiB (2,097,152 bytes) |
| 400 | bad_root | root is not 64 lowercase hex characters |
| 400 | bad_timestamp | first_ts, last_ts or published_ts is not RFC 3339 with a zero offset (Z or +00:00) |
| 400 | bad_slug | slug is given and is not 3 to 64 lowercase letters, digits and single hyphens |
| 400 | bad_device_id | device_id is not a UUID |
| 400 | bad_signature | signature is not 128 hex characters |
| 404 | unknown_device | No device with that id; "register the device with POST /v1/devices first". The CLI does exactly that and retries once |
| 401 | bad_signature | The signature does not verify against the device's key over the message below |
| 403 | slug_taken | The slug exists and belongs to another device |
| 500 | internal | Something on the server side; the cause is in the log with the request id |
On success with no slug, the server mints one and tries up to ten random candidates against existing rows, deleted ones included. With a slug the device owns, the page's html, root and timestamps are overwritten and a new roots row is appended. received_at is set by the server and never read from the request.
GET /p/{slug}
The stored HTML, exactly the bytes the client published, as text/html; charset=utf-8 with Cache-Control: public, max-age=60 and X-Content-Type-Options: nosniff. A missing, invalid or deleted slug is a plain-text 404 page not found, not the JSON envelope, because this route serves people, not clients. GET and HEAD are both routed: a HEAD answers with the same status and headers and no body, so curl -I works. Any other method answers 405. Pages are framable on purpose: the landing page embeds one in an iframe.
GET /v1/verify/{slug}
The record behind the badge. Never includes HTML.
response 200 {"slug": "north-fable-4xt3",
"root": "6cc1406d82499fc6f69483a603f1f5efe04ac8767edd31674a4f1afaf3b18535",
"first_ts": "2026-09-18T07:53:48.617Z",
"last_ts": "2026-09-18T07:53:49.507Z",
"received_at": "2026-09-18T07:53:50.471573Z",
"device_id": "5ea56900-b0d3-4683-aa32-fe1e3eca821c"}
404 not_found no such page, or a deleted oneDELETE /v1/pages/{slug}
Clears the HTML and stamps deleted_at. The root column and the whole roots history survive, and the slug stays reserved. Idempotent.
request {"device_id": "...", "signature": "<128 hex>"}
response 200 {"slug": "north-fable-4xt3", "deleted": true}
400 bad_request / bad_device_id / bad_signature as above
404 unknown_device no such device
401 bad_signature does not verify for this device
404 not_found no such page
403 not_owner the page belongs to another devicePOST /v1/webhooks/lemonsqueezy
Verifies X-Signature and files the raw payload in billing_events. Nothing else happens in this version; there is no plan gating.
response 200 {"ok": true}
503 not_configured LEMONSQUEEZY_SECRET is empty
413 too_large body over 1 MiB
401 bad_signature X-Signature does not match the body
400 bad_request the body is not valid JSONGET /healthz
200 {"status":"ok"} when Postgres answers a ping within two seconds; 503 {"status":"degraded"} otherwise. HEAD /healthz is routed too, for uptime checkers that use it, and answers with the same status and no body. Successful health checks are logged at debug level so they do not drown the log.
Unknown paths under any prefix answer 404 {"error":"not_found","message":"no such endpoint"}; a wrong method answers 405 method_not_allowed. Every request has a 30-second timeout.
Signature scheme
Keys are ed25519. Everything on the wire is lowercase hex: 64 characters for a public key, 128 for a signature, 64 for a root. The server verifies over the exact bytes below, built from the strings in the request body as sent, so a client must not re-format anything between signing and sending.
Publish, POST /v1/pages:
root + "\n" + published_ts + "\n" + slugOn a first publish the client has no slug, omits the field, and signs over the empty string, so the message ends with a newline:
6cc1406d82499fc6f69483a603f1f5efe04ac8767edd31674a4f1afaf3b18535\n2026-09-18T07:53:50Z\nRe-publishing an existing page:
6cc1406d82499fc6f69483a603f1f5efe04ac8767edd31674a4f1afaf3b18535\n2026-09-18T07:53:50Z\nnorth-fable-4xt3Delete, DELETE /v1/pages/{slug}, the slug taken from the URL path and never empty:
delete\nnorth-fable-4xt3The signature does not cover device_id, html, first_ts or last_ts. The device id is bound by the lookup of the key; the HTML is bound by nothing but the page's own root, which is what the badge shows. The billing webhook is different: X-Signature is a lowercase hex HMAC-SHA256 over the raw body, keyed with LEMONSQUEEZY_SECRET, compared in constant time.
Schema
db/migrations/0001_init.sql:
| Table | Columns |
|---|---|
devices | id uuid (generated), public_key bytea unique, email text (unused), created_at |
pages | slug text primary key, device_id → devices, html text, root text, first_ts, last_ts, published_ts, received_at (default now()), deleted_at (null until deleted) |
roots | id bigserial, slug → pages, root text, received_at; one row per publish, indexed by slug |
billing_events | id bigserial, received_at, payload jsonb |
Plus schema_migrations, maintained by the migrator. A deleted page keeps its row so the slug is never reused and the root history stays attached; only html is cleared.
Point the CLI at it
Per run, or for the repository:
$ madelog publish --server http://localhost:8080
$ madelog config set server.url http://localhost:8080
server.url = http://localhost:8080The default server.url is https://madelog-server-production.up.railway.app, the hosted instance as it is deployed today; it moves to a madelog.app address once that domain is attached. Setting it to your own origin is what makes a repository publish to your server instead.
server.device_id and server.slug in .madelog/config.toml belong to one server. When you move a repository between servers, clear them (madelog config set server.slug "" and the same for server.device_id) so the next publish registers afresh instead of trying to replace a page the new server never saw. A device id the new server does not know is a 404 that the CLI recovers from by registering again; a slug it does not know is simply minted fresh, but the old page stays up on the old server until you unpublish there.
Railway
The hosted instance runs on Railway in one project: a Postgres service and the server built from server/Dockerfile, both in the same EU region. What matters when you set up your own:
- The Dockerfile pins
LISTEN_ADDR=:8080; point the public domain at port 8080. Without the pin the server would readPORT, which Railway sets. - Set
DATABASE_URLas a reference to the Postgres service's variable (${{Postgres.DATABASE_URL}}) rather than a copied DSN, so a password rotation propagates on the next deploy. - Set
PUBLIC_BASE_URLto the public origin. It is baked into everyurlandverify_urlthe API returns; attach a custom domain and forget this variable, and clients get links to the old origin. - Healthcheck path
/healthz, timeout 60 s, restart policy on failure./healthzreturns 503 when the database is unreachable, so a failing check usually means Postgres, not the server. - Leave
LEMONSQUEEZY_SECRETunset until billing exists. The server logs one warning at startup and the webhook answers 503; that is expected. - The container exiting at once with a
config:line means a missing variable.
Deploys go from a checkout with railway up --ci from server/, or from a GitHub remote attached to the service. Either way, check the deployment list and the logs after; the command returning is not the same as the service being up. The full runbook, with the smoke test that walks a publish and a delete against a live instance with a throwaway key, is docs/deploy.md in the repository.
Checking a live instance
Without a key, three requests tell you the service is the madelog server and the database is up:
$ curl -sS https://madelog-server-production.up.railway.app/healthz
{"status":"ok"}
$ curl -sS -X POST https://madelog-server-production.up.railway.app/v1/devices \
-H "content-type: application/json" -d '{"public_key":"zz"}'
{"error":"bad_public_key","message":"public_key must be a hex-encoded 32-byte ed25519 public key"}
$ curl -sS -X POST https://madelog-server-production.up.railway.app/v1/webhooks/lemonsqueezy -d '{}'
{"error":"not_configured","message":"billing webhook is not configured"}With a key, madelog publish --server <origin> from any repository with a log, then madelog unpublish --server <origin>, exercises every endpoint but the webhook.