Upgrading Appwrite from 1.4.13 to 1.9.5 on CapRover: A Field Guide Through Five Years of Breaking Changes
TL;DR
What looked like a routine "bump the Docker tag" upgrade turned into a multi-hour dig through migration internals, CLI argument parsing quirks, a whole new microservice I didn't know existed, and an EJS templating bug that only showed up on long lines. If you're running self-hosted Appwrite on CapRover and haven't upgraded past 1.4.x, here's everything that actually needs to happen — and why the obvious approach doesn't work.
The setup
I've been running Appwrite 1.4.13 self-hosted on CapRover for a while — a backend-as-a-service platform handling auth, databases, and storage for a few projects. It's been solid, but 1.4.13 is old at this point, and I wanted to get current. My plan was simple: bump the CapRover image tag to something newer, let it redeploy, done.
It was not done.
First failure: authentication just breaks
The moment I pointed CapRover at a newer tag, login stopped working. No useful error in the browser, just a broken console. Digging into the container logs gave the first real clue:
Appwrite\Extend\Exception: Unknown resource type
at /usr/src/code/app/controllers/general.php:792
This traced back to Appwrite's custom-domain router. Every incoming request — including requests to your own API domain — gets matched against a rules collection that decides whether to serve it as an API call, a redirect, or a function/site deployment. That collection has a type attribute that determines the routing behavior.
My rules row predated that attribute entirely. It was created back on 1.4.13, when the schema only had a field called resourceType. The type field — and the migration logic that converts resourceType into it — was added in a later internal schema version. I'd jumped straight past it.
The real lesson: Appwrite's migrations don't chain automatically
Appwrite tracks an internal schema version per release (V19, V20, V21...) and ships a migrate CLI command to walk your data through schema changes. I assumed — reasonably, I thought — that running migrate against a fresh image would walk through every schema change since your last version automatically.
It doesn't. Each invocation of migrate takes a single target version and runs only that version's migration class. There's no chain. Jump from 1.4.13 straight to 1.9.5 in one migrate call, and you silently skip every intermediate schema change — including, in my case, the exact one that fixes the "Unknown resource type" bug.
The fix was to invoke migrate once per intermediate version, in order:
docker exec -it <container> migrate version=1.5.0
docker exec -it <container> migrate version=1.6.2
docker exec -it <container> migrate version=1.7.4
docker exec -it <container> migrate version=1.8.1
docker exec -it <container> migrate version=1.9.5
Two gotchas buried in there:
-
version=X, not positionalX. Appwrite's CLI framework parses arguments askey=value, not by position. Runningmigrate 1.5.0doesn't error — it silently falls back to the default version and runs the wrong migration. I burned five identical runs before noticing every single one printedStarting data migration to version 1.9.5regardless of what I typed. -
_APP_DB_ADAPTERnow defaults topostgresql. My install was MariaDB, like every pre-1.9 Appwrite install (multi-adapter support is newer than that). Without explicitly setting_APP_DB_ADAPTER=mariadb, the new binary would have tried to speak Postgres wire protocol to a MariaDB server.
Second failure: the console vanishes
With the database schema finally caught up, the API worked — but the web console 404'd on every path. Turns out the console UI isn't bundled into the main image anymore. Somewhere around the SSR rework, it became its own service (appwrite/new, a TanStack Start app) that's expected to sit behind a reverse proxy splitting traffic by path: /v1/* and /.well-known/* go to the PHP API, everything else goes to the console.
My 1.4.13-era CapRover deployment only had the one API container — that split had never existed before. I had to:
- Deploy the console as its own CapRover app (
appwrite/new:0.3.32, port 3000, three env vars). - Hand-write a custom Nginx config for the API app replicating that path split, since CapRover's proxy is Nginx, not the Traefik setup Appwrite's official docker-compose file assumes.
Third failure: the config that wouldn't stick
Twice, my carefully-written custom Nginx config silently reverted to CapRover's bare default template after I touched other App Config settings — a known CapRover behavior where certain saves regenerate the proxy config from scratch. And once, while debugging, CapRover's build log threw:
Error: Could not find matching close tag for "<%-".
This wasn't a real syntax error in the template — it was long lines getting silently truncated mid-word somewhere in the copy path between chat, terminal, and CapRover's textbox. The fix was almost comedic: reformat the entire config so no line exceeded ~60 characters, since nginx doesn't care about line breaks between tokens. Once every line was short enough to survive whatever was truncating it, it stuck.
What finally worked, end to end
- Set
_APP_DB_ADAPTER=mariadbbefore touching anything else. - Ran
migrate version=Xfor every intermediate version between 1.4.13 and 1.9.5, in order, verifyingMigration completed(not just a version number) after each one. - Deployed a separate
appwrite-consoleapp runningappwrite/new. - Split Nginx routing on the API app between
/v1/*→ API and everything else → console. - Fixed
_APP_DOMAIN(previouslylocalhost, a legacy placeholder that broke asset URLs and CORS on the newer console).
If you're doing this yourself
- Don't trust a single
migratecall to catch you up across major versions — checkMigration::$versionsin the source for the exact intermediate version strings, and run each one explicitly. - Confirm your database adapter env var explicitly. Don't rely on defaults changing under you.
- If you're self-hosting on anything other than the official docker-compose file (CapRover, a hand-rolled Swarm stack, whatever), expect to manually replicate any new services that landed since your last upgrade — check the current
docker-compose.ymlfor services you don't have yet. - When a config "mysteriously" breaks after being fine moments before, check whether something else you touched regenerated it from a template.
Top comments (0)