Skip to content

Self-hosting ​

Spelling Creator's own instance runs on Cloudflare, and will keep doing so. But nothing about the app requires it: the same API runs as a plain Node process against Postgres and any S3-compatible object store, which is what this page is about.

bash
node apps/api/src/node/server.js
# or: pnpm --filter @spelling-creator/api start:node

That serves the whole route table from apps/api/src/app.js (the lesson hub, images, git history, proposals, moderation, notifications, profiles, feeds, the AI features), the built SPA and docs from disk, and server-rendered HTML for the public read routes. The platform seam explains how the same route code runs on both hosts.

With Docker ​

The repository ships a Dockerfile and an example docker-compose.yml that stands the whole thing up: Postgres, PostgREST, GoTrue, RustFS and a Caddy gateway around the app.

The object store is RustFS rather than MinIO, whose community edition is no longer somewhere to point self-hosters. Nothing in the app knows which it is (it speaks plain S3), so Garage, SeaweedFS, Ceph RGW or real S3 are a change of image and endpoint and nothing else. The service is named storage for its role rather than its implementation to keep that swap a one-line edit.

bash
./scripts/generate-env.sh   # writes .env with every credential filled in
# then set PUBLIC_URL / PUBLIC_HOSTNAME in .env
docker compose up -d --build

The generator refuses to overwrite an existing .env; --force mints new credentials into one anyway, and --print writes nothing and prints a fresh set. It is POSIX sh and openssl, so it needs nothing installed; asking someone whose only requirement is Docker to add a Node toolchain to produce a config file would be a silly thing to ask. If openssl somehow isn't there, any container has it:

bash
docker run --rm -v "$PWD:/w" -w /w --entrypoint sh alpine/openssl:latest scripts/generate-env.sh

--entrypoint sh is not optional: that image's entrypoint is openssl itself, so without it Docker runs openssl sh scripts/generate-env.sh and the script never executes.

Use the generator rather than copying .env.example by hand. ANON_KEY and SERVICE_ROLE_KEY are JWTs that must be signed with the same JWT_SECRET PostgREST and GoTrue verify against, and a value that is not a valid token fails in a way that points everywhere except at itself: PostgREST reports a signing error, GoTrue reports a permissions error, and the app answers 502. The dependency check below catches it directly (its credentials line), but not generating a broken one is better still.

PUBLIC_URL is baked into the SPA at build time, so set it before the build, not after.

That is the whole of it: there is no schema step to remember, because the ordering it depends on is not something to leave to a reader. A one-shot schema service waits for GoTrue to create auth.users on its first migration, then applies apps/api/schema.sql with ON_ERROR_STOP, grants the new tables to service_role, and the app waits for it to finish. A second one-shot service, storage-init, creates the two buckets (lesson-images and lesson-git) the same way.

Both halves of the schema step matter. Every hub table references auth.users, so applying the schema before auth has migrated fails every statement, and psql walks past errors and exits 0, so the failure is silent. What you get is an empty database, PostgREST reporting 0 Relations, and the app answering 502.

The related trap is PostgREST's schema cache, which it builds once at startup: a table created afterwards is invisible to it. The stack installs the same pgrst_watch event trigger Supabase ships, so any DDL tells PostgREST to re-read. If you ever suspect it has gone stale anyway, docker compose restart postgrest settles it.

Filling in .env is not optional. Every credential is declared as a required variable, so docker compose up without a filled-in .env stops and names what is missing rather than standing the stack up with blank passwords. The SMTP settings are the exception and default to empty, because the default AUTH_MODE=password needs no mail at all; see Sign-in for what is missing without them.

Two things about it are worth understanding before adapting it.

One origin serves both /rest/v1/* and /auth/v1/*. That is how Supabase presents PostgREST and GoTrue, and this API was written against it. Nothing in the compose file is Supabase: the gateway service is an ordinary reverse proxy joining the two upstream projects under one origin, doing the job Kong does on Supabase's own hosting. It is also the proxy that should sit in front of the app anyway.

The VITE_* values are build arguments, not runtime environment. Vite substitutes them into the SPA bundle at build time, so they are baked into the image: an instance pointed at a different public URL needs docker compose build, not just a restart. That is a property of shipping a static SPA rather than a choice made here.

The compose file terminates no TLS and takes its credentials from .env with example values, both deliberately. It is a starting point, not a deployment; a compose file that pretended otherwise would be worse than one that is obviously an example.

When something doesn't work ​

Start here, before reading any of the rest of this page:

bash
docker compose logs app

The server asks every dependency whether it is actually working, once it has started listening, and prints the answer:

text
Dependency check:
  [ok  ] configuration: database and identity credentials are set
  [ok  ] cross-origin: requests are accepted from spelling.example.com
  [ok  ] credentials: the service-role key is a JWT whose role claim is service_role
  [ok  ] database: PostgREST answered (HTTP 200)
  [FAIL] schema: PostgREST cannot see public.lessons (HTTP 404 PGRST205: Could
         not find the table 'public.lessons' in the schema cache)
         Either apps/api/schema.sql was never applied, or PostgREST has a stale
         cache. Check the tables exist, then restart PostgREST if they do.
  [ok  ] identity: the auth service is healthy
  [ok  ] identity-admin: the admin API accepts the service-role key
  [ok  ] images: the bucket exists and is readable
  [FAIL] lesson-history: the bucket does not exist (NoSuchBucket ...)
         Create it, or point the bucket variable at one that exists.
  [ok  ] key-value: a value round-tripped
  Something above is broken; the app will not work correctly until it is fixed.

Each check is written to distinguish causes rather than to confirm health, because the causes are what look alike from outside. Reaching PostgREST is not the same as PostgREST being able to see the tables. Reaching the auth service is not the same as being allowed to call its admin API; that one looks like a working instance right up until somebody opens a profile. A bucket that answers is not the same as a bucket that exists. A check whose service isn't configured at all is shown as -- (skipped) rather than as a failure.

Results carry the upstream's own error code, not a paraphrase, because PGRST205 and NoSuchBucket are the strings worth searching for. The checks live in apps/api/src/lib/diagnostics.js.

To ask again without restarting, set ADMIN_TOKEN in .env (the compose file passes it to the app as ADMIN_MIGRATE_TOKEN) and send it as a header:

bash
curl -H "X-Admin-Token: $ADMIN_TOKEN" "http://localhost:8080/_diagnostics?format=text"

Without ?format=text the same report comes back as JSON. The answer is a 200 whether or not a check failed (the body is the answer), and a 503 if no token is configured at all.

GET /_health is separate, public and cheap: it answers whether the process is up and routing, and deliberately checks nothing else. A health check that fails when a dependency hiccups makes an orchestrator restart a process that was fine. The Docker image's own HEALTHCHECK follows the same rule by fetching /robots.txt, which needs neither the database nor object storage.

What you need ​

PieceHosted instanceSelf-hosted
RuntimeCloudflare WorkersNode 20 or newer (the image uses Node 24)
DatabaseSupabase PostgresPostgres + PostgREST
IdentitySupabase AuthGoTrue
Object storageR2RustFS, Garage, Ceph RGW, SeaweedFS, B2, S3
Expiring KVWorkers KVa table in the same Postgres
Response cachecaches.defaultyour reverse proxy
AIGemini, OpenAI, etc. or Workers AIthe same, or a local Ollama / vLLM

Node 20 is past end of life, so prefer 22 or newer when running outside Docker.

The database layer is the part that is already vendor-neutral and always was: this API talks to Postgres over PostgREST and to identity over GoTrue, both ordinary HTTP APIs with open-source servers. There are no stored procedures and no supabase-js on the server, so running the two upstream projects yourself needs no code change, only different URLs. Self-hosted Supabase works equally well and bundles both.

What you don't get ​

Three features need Cloudflare specifically, and are simply not registered on this host rather than stubbed; a stub would be a promise the process can't keep.

  • Live collaboration. A Durable Object per session, which is the part that gives one authoritative room per share code globally. Everything else about the editor works; /collab is not registered here, so it reaches the frontend fall-through and answers 404.
  • The remote MCP endpoint. Also a Durable Object, plus a KV-backed OAuth 2.1 server. The MCP server itself is unaffected and still runs over stdio, which is how most people use it anyway.
  • Crawler prerendering and og-image screenshots. Both need Browser Rendering. This matters less than it sounds: server rendering already covers /hub, /hub/:id (and its tabs) and /users/:id with real React for every visitor, and it runs here unchanged. What is lost is a headless-Chromium snapshot of / for crawlers, and link-preview images.

Configuration ​

Everything is environment variables. apps/api/src/node/platform.js reads the storage ones; the rest are read by the routes as they would be on the Worker.

Database and identity ​

VariableMeaning
SUPABASE_URLBase URL serving /rest/v1 (PostgREST) and /auth/v1 (GoTrue).
SUPABASE_SERVICE_ROLE_KEYBypasses RLS. Server-only; never ship it to a browser.
SUPABASE_ANON_KEYThe publishable key the SPA uses for sign-in.

The names are historical: they are the two upstream projects, whoever runs them. Apply apps/api/schema.sql once (the compose stack does it for you); it creates the hub tables and the kv_store table this host needs. It does not create the anon, authenticated and service_role roles PostgREST switches between, which Supabase already has; the compose file's roles-sql config shows what a plain Postgres needs.

Object storage ​

VariableRequiredMeaning
S3_ENDPOINTyese.g. http://storage:9000
S3_ACCESS_KEY_IDyes
S3_SECRET_ACCESS_KEYyes
S3_BUCKET_IMAGESyesLesson images, keyed by content hash.
S3_BUCKET_GITyesLesson history packfiles.
S3_REGIONnoDefaults to us-east-1, which MinIO and Garage expect.
S3_FORCE_PATH_STYLEnoDefaults to path style. false for AWS virtual hosts.
S3_SESSION_TOKENnoFor temporary credentials.

Two buckets rather than prefixes in one, matching how the Cloudflare deployment is arranged, so moving between hosts is a copy rather than a rename. Without object storage the instance still starts: it serves the hub and refuses image uploads with a clear error.

S3_REGION has to match what the store signs against, because SigV4 includes the region and a mismatch is a 403 that looks like bad credentials. In the compose stack the app and storage-init read it from the same variable.

The server itself ​

VariableDefaultMeaning
PORT8787
HOST0.0.0.0
WEB_DISTapps/web/distThe built SPA, with the docs site inside it.
ALLOWED_HOSTNAMES(none)Comma-separated hostnames allowed to call the API cross-origin.
CLIENT_IP_HEADERx-forwarded-for
TRUSTED_PROXY_COUNT1How many proxies sit in front of this process.
AUTH_MODEmagic-linkMust match the SPA's VITE_AUTH_MODE; see Sign-in.
USERNAME_DOMAINusers.noreply.invalidMust match the SPA's VITE_USERNAME_DOMAIN.
ADMIN_MIGRATE_TOKEN(none)Gates /_diagnostics and the one-time admin backfills.

ALLOWED_HOSTNAMES can stay unset when the SPA and the API share an origin, as they do in the compose stack, but the AI features need it either way (see AI).

TRUSTED_PROXY_COUNT is worth getting right. x-forwarded-for is a list that each hop appends to, and the client controls what is at the front, so the trustworthy entry is the one your nearest proxy added, counted from the right. The IP is what bans and rate limits are keyed on, so reading the leftmost entry (the usual mistake) would let any caller forge both. With one reverse proxy the default is correct; behind a CDN and a proxy, set it to 2.

Set it to 0 if this process is exposed directly, with nothing in front of it. Then no hop wrote the header, so nothing in it is believed and requests are treated as having no IP: IP bans stop matching, and the AI rate limiter keys everyone to one shared bucket. That is worse than having a proxy, and much better than the alternative: a directly-exposed process that trusts the header lets a banned visitor choose the address their ban is keyed on.

A CLIENT_IP_HEADER other than x-forwarded-for (nginx's x-real-ip, say) is taken as a single value, and TRUSTED_PROXY_COUNT doesn't apply to it.

Sign-in ​

The hosted instance signs people in with a magic link, which is the nicer experience and needs a mail server. A self-hosted one frequently has no SMTP at all, which would otherwise leave it with no way for anyone to sign in, so the instance chooses:

AUTH_MODEWhat the login page offersNeeds mail
passwordUsername and passwordno
magic-linkA one-time emailed linkyes
bothBoth; sign in with eitheryes

Anything else reads as magic-link, so a typo falls back to the default rather than to an instance nobody can get into.

Passwords are username-based, not email-based: asking somebody for an address you will never send anything to is asking for a detail nobody needs. With both, the sign-in field takes a username or an email address, decided by whether the value contains an @ rather than by a toggle to find.

Under the hood a username is given to the identity service as an address under USERNAME_DOMAIN: it authenticates by address and has no notion of a username. The default domain (users.noreply.invalid) uses a top-level domain reserved by RFC 2606, so it can never resolve, and nothing is ever sent there. If you change it, set it in both places: the SPA bakes it in at build time as VITE_USERNAME_DOMAIN, and the server reads it at runtime to resolve the username an admin types into POST /mod/password. The two disagreeing is a password reset that answers "no account with that username". Two things follow for free: usernames are unique, because the service will not register the same address twice; and signing in needs no lookup, so no public endpoint has to answer "which email belongs to this username?".

A username is not a display name. Display names are moderated for profanity and banned names, may contain spaces, and are deliberately not unique: two people may both be "Alex Morgan". A username is unique, never shown to anybody else, and exists only to sign in with. Registration deliberately does not set a display name from it, which would route around those checks.

With magic-link or both, the sign-in email carries a one-time code as well as the link. The installed app needs the code, because an emailed link opens in the browser instead (see Hub and accounts). Nothing needs setting up for that here: the GoTrue version the compose file pins (supabase/gotrue:v2.177.0) has the code in its built-in magic link and signup confirmation emails ("Alternatively, enter the code: ...").

Check this before upgrading GoTrue. Newer releases rewrote those built-in emails without the code. If the new version's don't have it, point GOTRUE_MAILER_TEMPLATES_MAGIC_LINK and GOTRUE_MAILER_TEMPLATES_CONFIRMATION at your own templates that include {{ .Token }}. GoTrue takes each one as a URL and fetches it, so the file has to be served from somewhere the auth container can reach.

The compose file defaults to password, because an instance reaching for it is more likely to have no mail server than to have one. It passes the same AUTH_MODE and USERNAME_DOMAIN to the SPA build and to the server, so the two cannot disagree. AUTH_MODE is baked into the SPA at build time, so changing it means a rebuild.

Two things follow from running without mail, and both are worth deciding deliberately rather than discovering:

  • New accounts are auto-confirmed (AUTH_AUTOCONFIRM, on by default), because a confirmation nobody can receive would make registration a dead end. That means anyone can register with an address they don't own. An instance open to the internet with no SMTP should also set GOTRUE_DISABLE_SIGNUP=true and create accounts by hand.

  • There is no self-service password reset. Resetting one the ordinary way means emailing a link. Instead, an admin sets the password from the moderation page (POST /mod/password, taking a username or an email; see Moderation). Admin-only, never moderator: setting somebody's password is taking their account, which is a different kind of power from hiding a lesson. An admin may reset their own and anybody below them, but not another admin's: admins are peers, and taking a peer's account is an escalation the tier was never meant to allow.

    Two things follow. The last admin locking themselves out is a database problem, not an in-app one. And the reset changes the password without necessarily ending sessions already open elsewhere, so treat it as recovery from forgetfulness rather than as containment of a compromised account.

With SMTP configured, set AUTH_AUTOCONFIRM=false so addresses get verified properly.

Passwords must be at least 8 characters. The login form and POST /mod/password check that, and GOTRUE_PASSWORD_MIN_LENGTH enforces it, so all three agree.

AI ​

Any of the hosted providers, or a local model through openai-compatible: Ollama, llama.cpp, vLLM, LM Studio. The compose file has a commented-out ollama service and the three variables to go with it. An instance with no AI configured serves everything else; the suggestion buttons report that they are unavailable.

Every AI request (suggestions, fact checks, AI fixes, and Pixabay image search, which goes through the same endpoint) is checked with Cloudflare Turnstile before anything else happens. Turnstile is a free service and works on any host, but the instance needs a widget of its own:

  • VITE_TURNSTILE_SITE_KEY at build time, so the SPA shows the challenge. Without it, the AI and image-search dialogs say they are unavailable.
  • TURNSTILE_SECRET_KEY at runtime, so the server can verify it.
  • ALLOWED_HOSTNAMES set to the hostname the app is served from, which the server compares with the hostname Turnstile reports.

The compose file sets none of these, so add them to use the AI features there. Pixabay search also needs PIXABAY_API_KEY. Wikimedia Commons search needs nothing, since the browser talks to Wikimedia directly.

Notes on running it ​

Put a reverse proxy in front. The process serves static files itself so that it can run alone, but Caddy or nginx will do it better: ranges, compression, and a real response cache, which is the one platform service this host deliberately implements as a no-op. Terminate TLS there too.

If you do let the proxy serve apps/web/dist, send everything it has no file for to the app rather than reaching for the usual SPA rule (try_files $uri /index.html). The app knows its own route table and answers a path that isn't a route with a real 404; a blanket index.html fallback in front of it puts that back to a 200, which is a soft 404 on every dead link and every probe for a well-known URI. See Pages and routing for how the app decides.

It is stateless. Run as many processes as you like behind the proxy; nothing is held in memory between requests. The process shuts down cleanly on SIGTERM.

Expired kv_store rows are treated as absent on read and swept as they are passed, so nothing depends on housekeeping. schema.sql has a pg_cron snippet to reclaim the rows nobody asks for again.

Migrating from Cloudflare is a bucket copy and a database dump: the object keys and the schema are identical, which is deliberate.

Copyright © 2026 Spelling Creator.