Μετάβαση στο περιεχόμενο

Installing and running Catodon

Λειτουργία διακομιστή 30 λεπτά ανάγνωσης Πηγή στο codefloeΜόνο στα αγγλικά

Σε αυτή τη σελίδα

Catodon is Sharkey at heart, so the Sharkey installation guide still applies for the base setup. Use the Catodon repository (https://codefloe.com/catodon/catodon) instead of Sharkey's.

For production, use the latest stable Catodon release tag or the main branch. Use develop if you want the next release in progress.

For a development instance rather than a production one, see Development.

Migrating from Misskey

See Migrating from Misskey to Catodon.

Migrating from Sharkey

Point your server to Catodon's repo, run git pull, pnpm install, pnpm build, stop the service, run pnpm migrate, and start the service again.

For Docker installations, rebuild with docker compose build --no-cache. Before starting the rebuilt image, change the two volume lines in your compose file from /sharkey to /catodon, since Catodon's image serves the application from a directory of its own name. A compose file inherited from Sharkey will always still have the old paths, and an image that cannot find its configuration exits at startup. See Docker below for the exact lines and the migrate command to run inside the container.

Always take a database and files backup before migrating — see Backing up before an upgrade for step-by-step instructions, Docker and bare metal.

One upgrade has a runbook of its own, because it touches the in-memory store rather than the database: moving from Redis to Valkey (Upgrading your in-memory store from Redis to Valkey), which is lossy if you don't drain the job queue first.

Moving a Docker install's database to PostgreSQL 18 has its own steps too, in Upgrading PostgreSQL: changing the image line alone leaves the database refusing to start.

Let pnpm migrate finish, however long it takes. On an instance with a few million notes it runs for several minutes, and ten minutes or more is normal on a large or slow database — it grows with your note count and with every release that adds migrations. It prints each statement as it goes, so a long pause on one line means that statement is still working, not that anything has hung. Nothing reports progress as a percentage, and there is no way to tell from the output how much is left.

Interrupting it is what causes real damage. Migrations are applied one at a time and each is recorded only once it succeeds, so a Ctrl-C leaves the database part-migrated: the rest of the chain still runs when you try again. Some of the slowest steps build indexes without locking the table, and those cannot be rolled back cleanly — a cancelled build leaves an unusable index behind, and a later run may skip over it as though it were finished.

There is no safe point to stop it part-way. Nothing waits for the statement in flight: Ctrl-C kills the process where it stands, and so does a shutdown, a dropped SSH session or a container restart, which is why it is worth running under screen or tmux. If a run does get cut short, treat the database as part-migrated: check for leftover indexes as described below, then re-run pnpm migrate to finish the chain. Re-running is safe and is the intended way to recover — the migrations that already succeeded are recorded and will not repeat.

If a migration run did fail or was interrupted, check for leftover indexes before carrying on. This should return no rows:

SELECT
  i.indexrelid::regclass AS invalid_index,
  CASE
    WHEN c.relkind = 'I' THEN 'partitioned parent — leave alone'
    WHEN i.indrelid = to_regclass('"note"') AND c.relname IN (
      'IDX_note_text_trgm', 'IDX_note_global_hot_candidates',
      'IDX_note_forum_root_id', 'IDX_note_forum_root_forumId_id',
      'IDX_note_forum_root_forumId_active', 'IDX_note_forum_root_active',
      'IDX_note_local_root_active_sort', 'IDX_note_global_root_active_sort'
    ) THEN 'drop it, pnpm migrate rebuilds it'
    WHEN i.indrelid = to_regclass('"user"')
      AND c.relname = 'IDX_user_private_forum_actor'
      THEN 'drop it, pnpm migrate rebuilds it'
    WHEN i.indrelid = to_regclass('"note"') AND c.relname IN (
      'IDX_note_local_active_sort', 'IDX_note_global_active_sort',
      'IDX_note_forum_active_sort'
    ) THEN 'superseded — drop it, it does not come back'
    ELSE 'not built by a migration — rebuild it yourself'
  END AS what_to_do
FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
WHERE NOT i.indisvalid;

Run it as the database user Catodon itself uses, so that note and user resolve to Catodon's own tables. The check is on the table an index belongs to, not just its name, so an unrelated index that happens to share a name with one of Catodon's is classified on its own merits rather than being mistaken for one of these.

::regclass prints each name in the form that resolves back to that exact index — quoted, and prefixed with its schema when the schema is not already on your search_path. Paste it into the commands below exactly as printed, prefix and all. Index names are only unique within a schema, so re-typing a prefixed name without its prefix can resolve to a perfectly healthy index of the same name elsewhere, and you would drop that one instead of the broken one.

Most of what it lists is an index Postgres never finished building: not used by any query, but still maintained on every write, so it costs you speed without giving any back. The exception is the partitioned-parent case below, which is not damage at all.

The what_to_do column matters, because not every invalid index is one a migration will rebuild. Only the names listed in that query are built online by Catodon's migrations; an index you or a tool created, or one left behind by a failed REINDEX CONCURRENTLY, is yours to rebuild, because pnpm migrate knows nothing about it and will not recreate it. A partitioned parent index is a different case again: Postgres marks it invalid on purpose until every partition has a matching index, so it is not damage and must not be dropped. Catodon does not partition any of its own tables, so that only arises if you added partitioning yourself.

The superseded case is for three indexes a later migration removes on purpose — they were replaced by better ones. On an up-to-date server they should not be there at all, so drop them and do not expect them back.

For an index the query says a migration rebuilds, drop it and let the migration build it again, using the name exactly as printed:

DROP INDEX CONCURRENTLY "IDX_example";

then re-run pnpm migrate. REINDEX INDEX CONCURRENTLY "IDX_example"; also works when whatever interrupted the first build has passed — a timeout or a cancelled run, which is the usual case. But if the cause is still there, the reindex fails the same way and leaves a second invalid index behind, named after the first with a _ccnew suffix, which you then have to drop as well. Dropping first avoids that, and costs nothing extra: the migration rebuilds the index either way.

Going back the other way is a different matter. Catodon does not intentionally delete inherited data, but its schema has diverged, and some of what Catodon stores has nowhere to go in Sharkey's tables. If you may want to return, read Downgrading a Catodon instance to Sharkey before you migrate, not after: one of its steps has to happen while Catodon is still running, and one class of damage (forum actors cached by remote servers) cannot be undone at all.

Docker

Pre-built images are published to codefloe.com/catodon/catodon, one tag per release. What is currently published is listed at https://codefloe.com/catodon/-/packages; pick a version from there rather than guessing one:

CATODON_VERSION=26.8.0-rc.1 # Replace with the version you chose above.
docker pull "codefloe.com/catodon/catodon:${CATODON_VERSION}"

Images are currently built for linux/amd64 only. If you need linux/arm64, say so on the issue tracker: adding it requires another build machine.

compose_example.yml is a working starting point. Two configuration files have to be in place before the first docker compose up, and both are mandatory:

  • Copy .config/docker_example.yml to .config/default.yml, not example.yml. The two differ in ways that matter inside a container: example.yml binds the server to 127.0.0.1, which is right for a reverse proxy on the same host but means nothing answers on the published port from inside a container, and it does not point the database and Valkey at the db and valkey service names.
  • Copy .config/docker_example.env to .config/docker.env. The db service loads it unconditionally, so compose refuses to start anything at all without it, and the credentials in it have to match the db block of default.yml.

The container serves the application from /catodon and runs as UID and GID 991.

It reports its health by polling /healthz, which needs to reach the server over TCP on port 3000. If you serve on a different port, set MK_CONFIG_PORT in the container environment as well as in your config file, since the check cannot read the config file. If you serve on a UNIX socket, turn the check off with healthcheck: disable: true in your compose file: nothing else in the image depends on it, but a container that can never report healthy will keep an orchestrator restarting it.

Upgrading from a compose file with a redis service: the bundled store is now Valkey, and the service is called valkey. It carries a redis network alias, so a .config/default.yml that still says host: redis keeps working, and the redis / redisFor* config keys are unchanged. For a step-by-step walkthrough of this migration (Docker and bare metal), see Upgrading your in-memory store from Redis to Valkey; the rest of this section is the detailed reference it's based on.

Whether your existing data can come across depends on which Redis wrote it, so check that first — the tag in your compose file does not tell you, because redis:7-alpine floated from 7.2 to 7.4 over its life. Run this against your current, unmodified compose file, while Catodon is still up and before the redis service is renamed:

docker compose exec redis redis-cli INFO server | grep redis_version

Then, still on the old configuration and still running, drain the outstanding work — see "Starting empty is lossy" below for why and how. The admin dashboard and the workers both go away the moment you stop the stack, so this cannot be done later. Only once the queue is clear:

docker compose down                 # still the old compose file
cp -a redis redis.backup            # nothing is writing to it now

Edit the compose file after that: rename redis to valkey, change the image and healthcheck, and set the volume according to the version you just read.

Redis 7.2.x or older: the files are compatible. Point the volume at the existing directory, or move it across (mv redis valkey) if you also change the volume line to ./valkey:/data.

Redis 7.4 or newer: they are not. Valkey's migration guide supports direct file reuse only from Redis 2.x–7.2.x and states that "Redis Community Edition (CE) version 7.4 and later produce data files that are not compatible with Valkey" — pointing Valkey at such a directory can leave it unable to start. Give the new service an empty directory instead:

    volumes:
      - ./valkey:/data

Starting empty is lossy, not free. Much of what is in there is cache that does rebuild: the main fanout timelines fall back to a Postgres query when their list comes back empty, as long as enableFanoutTimelineDbFallback is on, which is the default, and rate-limit counters start counting again. Four things do not come back, and draining the queue only addresses two of them:

  • Every account's notifications. There is no notification table. MiNotification is a plain type, and NotificationService appends each notification to a stream per user, notificationTimeline:<userId>, with the read state beside it in latestReadNotification:<userId> and readNonDirectMessageNotifications:<userId>. Discarding the store empties the notification list of every account on the instance, permanently, and nothing re-derives it. There is no drain step that helps, because there is nowhere to drain it to: only reusing the data file keeps it, which is the 7.2.x-or-older path. The symptom is easy to misread as healthy, because the Mentions tab keeps working: notes/mentions queries Postgres and never touches the store.
  • Custom feeds timelines. antennas/notes reads antennaTimeline:<antennaId> and answers an empty page when that list is empty; unlike the timelines above, it has no database fallback. Custom feeds come back blank and refill only as new matching posts arrive, so the back catalogue does not return.
  • Pending federation deliveries. The deliver queue holds activities that have not reached remote servers yet. Postgres has your notes, but it does not re-derive the outbound delivery attempts, so anything dropped here is silently never delivered — and failed deliveries sit in the queue as delayed retries, sometimes for hours.
  • Buffered reaction counts, if you turned that on. Reactions buffering is off by default, so skip this unless you enabled it. When it is on, ReactionsBufferingService accumulates deltas in the store and only writes them to Postgres when its bakeBufferedReactions job runs — which is a daily cron (01:20), not something that happens on shutdown, so discarding the store loses every delta since the last run and leaves those reaction counts permanently low. Switching Reaction buffering off in Control Panel -> Performance flushes them, and that flush is a job you can watch: it is queued on the system queue as bakeBufferedReactions, so it either completes or fails visibly, and BullMQ retries it if it fails.

So switch buffering off, then wait for that job to finish before you stop anything. Watch Control Panel -> Server health: the flush is done when the system queue has no bakeBufferedReactions job left in wait, active, delayed or prioritized. A failed one means the deltas are still owed — the job's error says why, and it is safe to retry.

The daily cron keeps running after you disable buffering, so a flush that did not get its chance is picked up rather than abandoned. A bake is also queued at startup, which is what recovers deltas from a bake that a restart cut in half.

If you want to confirm it from the CLI too, <your.host>:reactionsBufferDeltas:* and <your.host>:reactionsBufferInFlight* should both come back empty. Deltas are moved into the InFlight keys while they are being written and are only dropped once Postgres has committed them, so a leftover key there is a bake that did not finish, not a lost count — the next bake picks it up and applies it exactly once. Turn buffering back on whenever you like once you are running on Valkey.

So drain while the old stack is still running — this is the step referenced above, and it has to happen before docker compose down — and read the totals off Control Panel -> Server health rather than the CLI. Look at wait, active, delayed and prioritized together — wait alone reads empty while retries are still pending. If you do want a CLI check, note that the key prefix defaults to your instance's hostname when redis.prefix is unset, so the deliver key is <your.host>:queue:deliver:deliver:wait, not queue:...; a query without that prefix returns 0 and looks like an empty queue.

Third-party tools (RIOT, RedisShake, Redis-Migrate-Tool) can move data between servers, but Valkey's guide only mentions them in passing under cluster migration and calls them "beyond the scope of this document" — it does not validate them for the 7.4+ case, so treat that route as something to test yourself.

Keep the old service until you have checked the new one. The move is one-way in practice: once Valkey 9 has written its own dump, falling back to Redis or to Valkey 8 is not reliable. Leave the backup in place, confirm the queue is processing and reactions are counting on Valkey, and only then discard it.

If you would rather stay on Redis — a compatibility path for now, not the tested one, as above — keep both the old image: and the old healthcheck: lines — the redis image ships redis-cli, not valkey-cli, so taking the new healthcheck with the old image leaves the service permanently unhealthy and web never starts:

  valkey:
    image: redis:7-alpine
    healthcheck:
      test: "redis-cli ping"

Upgrading from an image that used /sharkey: the application directory moved to /catodon and its account was renamed to catodon. The UID and GID are unchanged, so nothing on disk needs touching, but the two volume lines in your compose file have to point at the new path:

    volumes:
      - ./files:/catodon/files      # was /sharkey/files
      - ./.config:/catodon/.config:ro   # was /sharkey/.config:ro

If they do not, the container starts, finds no configuration and exits, and pnpm run migrate fails with Failed to load configuration (older builds reported this less helpfully, as Unable to open file: "/catodon/packages/backend/ormconfig.js"). Fix the two lines, then finish the update:

docker compose run --rm web pnpm run migrate
docker compose up -d

If the mounts genuinely cannot move, MISSKEY_CONFIG_DIR and MK_CONFIG_MEDIADIRECTORY override the two paths individually.

Upgrading PostgreSQL

The db service of compose_example.yml runs PostgreSQL 18 with PGroonga, groonga/pgroonga:4.1.0-alpine-18. A new install starts on 18 and has nothing to do here. An existing Docker install runs whatever its own compose.yml names, which depends on where it came from:

  • PostgreSQL 15, 16 or 17. Catodon's earlier releases used 17, groonga/pgroonga:4.0.1-alpine-17, as Sharkey does from 2025.2.2; Misskey up to 2025.11 and older Sharkey used 15, postgres:15-alpine. None of these compose files used 16, so a 16 is one you moved to yourself; the steps are the same. The database has to move across once: PostgreSQL cannot read the data directory of another major version, so it is dumped from the old container and restored into the new one, with the steps below.
  • PostgreSQL 18 already. Misskey from 2025.12 uses postgres:18-alpine with the volume at /var/lib/postgresql. There is nothing to move; see Already on PostgreSQL 18 below.

To see which you have, look at the image: line of your db service, or ask the database: docker compose exec db postgres --version. Either way, your files, your configuration and Valkey are not touched.

Coming from 15, 16 or 17, changing the image line alone does not upgrade anything. From 18 on, the image keeps its database one level further down, in /var/lib/postgresql/18/docker, which is why the volume line changes too. Started on a ./db that an older version wrote, with the old volume line or the new one, the 18 container prints Error: in 18+, these Docker images are configured to store database data in a format which is compatible with "pg_ctlcluster", names the directory where it found the old data, and exits. restart: always starts it again and again, and web never starts, because it waits for a healthy database. Nothing is deleted, apart from an empty 18/docker directory the image adds inside ./db, so putting the old lines back undoes it. Do not get past the error by pointing the volume at a new, empty directory: the database then starts, Catodon runs its migrations on it, and you have an instance with no accounts and no posts under your old domain. If you run the repository's compose_example.yml itself rather than a copy, the update changes the two lines for you and you meet that error straight away: put the old lines back and follow the steps below.

Do the database on its own. Update Catodon first, the usual way, on the database you have, and check that it runs: this release still runs on 15 and newer, although its tests run on 18 only (see Requirements). The steps below then change only the database, so if something goes wrong you know which change caused it.

Catodon is down from step 1 until step 8, and the disk needs room for the dump and a second copy of the database, since the old one is kept. How long it takes grows with the size of the database. On a test instance of 127 MB the dump took one second and the restore four; nobody has timed it here on a large one, so if you need to know, restore the dump into a spare container first.

Run these from the directory that holds your compose.yml. They assume the service names and the ./db volume of compose_example.yml:

# 1. Stop Catodon but leave the database running. Nothing writes to the
#    database after this, so the dump below holds everything.
docker compose stop web

# 2. Dump the database with the old container and read the whole dump back.
#    "dump OK" appears only if pg_dump succeeded and the file is complete; if
#    it does not appear, stop here. Then note a few counts to compare with at
#    the end.
docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" -Fc "$POSTGRES_DB"' > catodon-db-old.dump &&
  docker compose exec -T db pg_restore -f /dev/null < catodon-db-old.dump &&
  echo "dump OK"
docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT (SELECT count(*) FROM note) AS posts, (SELECT count(*) FROM \"user\") AS accounts, (SELECT count(*) FROM following) AS follows"'

# 3. Stop everything and move the old database directory aside. Keep it. If a
#    db.old is left from an earlier attempt, rename that one first: mv would
#    otherwise try to move db into it.
docker compose down
if [ -e db.old ]; then echo "db.old exists: rename it first"; false; else mv db db.old; fi
  1. Change two lines of the db service in compose.yml, the image and the volume, and leave the rest of it, your command: line included, as it is:
  db:
    image: groonga/pgroonga:4.1.0-alpine-18
    # ...
    volumes:
      - ./db:/var/lib/postgresql

These are the same whichever version you come from, PGroonga image or not. To stay without PGroonga, use postgres:18-alpine instead. If your old volume line names a host directory other than ./db, use that directory in step 3 and in the new volume line.

# 5. Start the new database on its own. It sets up an empty database in
#    18/docker under your database directory, which takes a few seconds, and on the way it runs a
#    temporary server that can already look healthy; the loop waits for the
#    real one, which is the only one that listens on TCP, and gives up after two
#    minutes. The last command should print 18 and a minor version.
docker compose up -d db
SECONDS=0
until docker compose exec -T db pg_isready -q -h 127.0.0.1; do
  [ "$SECONDS" -lt 120 ] || { echo "The database is not accepting connections. See: docker compose logs db"; break; }
  sleep 2
done
docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "SHOW server_version"'

# 6. Restore the dump. --single-transaction makes it all or nothing: if it
#    stops at an error, nothing of it is kept, and it can run again once the
#    cause is fixed.
docker compose exec -T db sh -c 'pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --exit-on-error --single-transaction' < catodon-db-old.dump

# 7. A dump does not carry the statistics PostgreSQL plans its queries with.
#    Rebuild them before Catodon sends its first query.
docker compose exec -T db sh -c 'vacuumdb -U "$POSTGRES_USER" -d "$POSTGRES_DB" --analyze-in-stages'

# 8. The same counts as in step 2, then start Catodon. Its container runs
#    `pnpm migrate` before it starts, which finds nothing to do.
docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT (SELECT count(*) FROM note) AS posts, (SELECT count(*) FROM \"user\") AS accounts, (SELECT count(*) FROM following) AS follows"'
docker compose up -d

Then check the instance: the counts match, the timelines load, and the query for invalid indexes near the top of this page returns no rows. If you search with PGroonga (fulltextSearch.provider: sqlPgroonga), search for something you know is there.

PGroonga needs nothing extra on this path. If the old database had PGroonga, the dump says CREATE EXTENSION pgroonga without a version, so the restore installs the version the new image ships, 4.1.0, and builds every PGroonga index again from the restored posts. ALTER EXTENSION pgroonga UPDATE is not needed. It would be if you changed the PGroonga binaries under a database without dumping it, for example from 4.0.1-alpine-17 to 4.1.0-alpine-17 on the same major version; PGroonga lists every step from 4.0.1 to 4.1.0 as compatible, but that route has not been tried here.

Going back. If the new instance is not right, return to the old directory. Anything posted on 18 meanwhile is not in it.

docker compose down
# If this prints "db.new exists", stop: rename the db.new left by an earlier attempt first.
if [ -e db.new ]; then echo "db.new exists: rename it first"; false; else mv db db.new && mv db.old db; fi
# put the old image and volume lines back in compose.yml
docker compose up -d

Keep db.old and the dump until the new instance has run for a while and you are satisfied with it, and only then delete them. db.old belongs to the container's database user, so removing it may need sudo.

PostgreSQL's own pg_upgrade can be faster on a large database, but it needs the binaries of both versions side by side, and it refuses to combine a cluster without data checksums with one that has them: 18 turns them on for a new cluster, and the clusters that the 15 and 17 images created have them off. It is not described here and has not been tried with Catodon.

Already on PostgreSQL 18

A db service on postgres:18-alpine with its volume at /var/lib/postgresql, as Misskey's compose file has it from 2025.12, can switch to the PGroonga image in place, as long as the two images are built on the same base. When this was written they were: both PostgreSQL 18 on Alpine with musl 1.2.6 and ICU 78.1, and the PGroonga image opened the other's files unchanged. Images are rebuilt over time, so compare the one you run with the new one first, while your stack is still up (the first command needs the running db container to find its image):

for image in "$(docker compose images -q db)" groonga/pgroonga:4.1.0-alpine-18; do
  docker run --rm --entrypoint sh "$image" -c 'apk info -v 2>/dev/null | grep -E "^(musl|icu-libs)-[0-9]"'
done

If the version numbers before the -r differ, dump and restore as above instead. If they match, take a backup, as Backing up before an upgrade describes, then:

docker compose down
# change only the image line of the db service to groonga/pgroonga:4.1.0-alpine-18
docker compose up -d

This only makes PGroonga available; to search with it, set fulltextSearch.provider: sqlPgroonga and create its index, as .config/example.yml describes. It holds for the Alpine image only. The Debian-based postgres:18 is built on a different C library, which can sort text differently, so move a database it wrote to an Alpine image by a dump and restore as above, which rebuilds its indexes; switching it in place has not been tried here.

Requirements

  • Node.js 22.23.4+ on the 22 line, 24.22.1+ on the 24 line, or 26.12.1+. Those three even-numbered lines are what is tested and supported -- 22 and 24 are the current long-term-support releases, 26 is still Current -- and the odd-numbered ones are not. The published container image ships 24, so this only concerns a source install
  • PostgreSQL 15 or newer. Catodon is tested on 18, which is what the compose file runs. 15, 16 and 17 are not tested; 15 is the oldest version that can run the migrations at all
  • Valkey 8 or newer, and FFmpeg (see FFmpeg and video conversion)
  • Extra disk space for remote-file and external-media caches. Plan for at least 1 GB more than a comparable Sharkey installation, and more on active media servers.

Valkey is the store Catodon ships, tests and recommends. Redis 7.x still works because it speaks the same protocol and the redis / redisFor* keys are unchanged, but Catodon does not test it and the two are diverging, so a future Redis release may not be supported. See Upgrading your in-memory store from Redis to Valkey.

A server installed from source does not have to move to PostgreSQL 18. If you upgrade anyway, follow your distribution's procedure (pg_upgradecluster on Debian and Ubuntu), or dump and restore as Upgrading PostgreSQL does for Docker, and if you use PGroonga, install its package for the new version first. That route has not been tested here.

FFmpeg and video conversion

Catodon uses FFmpeg on every install: ffmpeg makes the thumbnail of each uploaded video, and ffprobe tells a video apart from an audio file in a video container, such as an .m4a or an audio-only .webm. Both are looked up on PATH, unless you set the paths below, which then take their place for these jobs too. The container image ships them; a source install needs the distribution's package:

Distribution Package
Debian, Ubuntu ffmpeg
Alpine ffmpeg
Arch ffmpeg
Fedora ffmpeg from RPM Fusion. Fedora's own ffmpeg-free is reported to leave out the H.264 encoder, which conversion needs (not checked here)

Video conversion is off unless you turn it on. With it on, the upload options to convert MKV to MP4, convert HEVC to H.264 (as iPhones record, and many browsers cannot play) and compress videos over 50 MB take effect; until then the web interface sends none of them. To turn it on, give both paths in your config file:

mediaConversion:
  ffmpegPath: /usr/bin/ffmpeg
  ffprobePath: /usr/bin/ffprobe

In the container image the binaries are at those same paths, and the two keys can also be set as MK_CONFIG_MEDIACONVERSION_FFMPEGPATH and MK_CONFIG_MEDIACONVERSION_FFPROBEPATH. The ffmpeg binary must have the libx264 and aac encoders. ffmpeg -hide_banner -encoders | grep -E 'libx264|aac' should list both. At startup, the server logs whether conversion is enabled, and if it is not, what was missing.

Before you turn it on: a conversion runs while the upload waits, on the same machine that serves the site. At most two run at once in each process by default, and further uploads wait their turn, first come first served. A long video can take minutes to convert, and a short one can wait behind two long ones, so either can take longer than a reverse proxy waits for an answer. The upload then shows as failed, but the server carries on: the conversion keeps its place and its slot until it finishes, and the file appears in the person's storage afterwards. How much CPU a conversion takes depends on the machine and the video; this has not been measured for this guide, so watch it on yours before relying on it.

maxConcurrentConversions changes the limit, for example to 1 on a server with a single CPU core:

mediaConversion:
  ffmpegPath: /usr/bin/ffmpeg
  ffprobePath: /usr/bin/ffprobe
  maxConcurrentConversions: 1

or MK_CONFIG_MEDIACONVERSION_MAXCONCURRENTCONVERSIONS=1 in the container's environment. It has no effect while conversion is off.

Small-instance process mode

By default the main process serves HTTP and forks a separate worker process to run the job queue. Set MK_DISABLE_CLUSTERING=1 to run both in a single process instead, which noticeably lowers idle memory use.

This is intended for small, single-admin instances, where that saving matters more than keeping the two roles in separate processes. Larger installations should stay on the default topology.

PostgreSQL settings

PostgreSQL's defaults are sized for a machine with very little memory, and neither distribution packages nor the db image in the compose file raise them. Once an instance has been federating for a while, two settings are worth checking.

Cache memory (shared_buffers)

This is the memory PostgreSQL keeps the most-used parts of the database in. The default is 128 MB, which stops being enough once the instance holds a few million notes — on one with 2.9 million, a single index on the notes table was 189 MB by itself. Whatever does not fit is read from disk, and timelines get slow, first loads worst of all.

How much to give it:

  • The server runs only Catodon, PostgreSQL and Valkey: about a quarter of the RAM — 512 MB on 2 GB, 1 GB on 4 GB, 2 GB on 8 GB.
  • The server also runs other services: less. Look at the available column of free -h and give PostgreSQL at most about a third of it. An 8 GB server that also hosts a Discourse forum is comfortable at 1 GB.
  • 1 GB of RAM or less: leave the default.

The change only takes effect after a PostgreSQL restart, so Catodon loses its database connection for a few seconds. On a regular server:

sudo -u postgres psql -c "ALTER SYSTEM SET shared_buffers = '1GB';"
sudo systemctl restart postgresql
sudo systemctl restart catodon   # or however you run Catodon, so it reconnects cleanly
sudo -u postgres psql -c "SHOW shared_buffers;"   # should print the new value

To go back to the default, run the first command as ALTER SYSTEM RESET shared_buffers; and restart both again.

With Docker, add it to the command: line of the db service in compose.yml, then recreate the database container and restart Catodon:

  db:
    command: postgres -c jit=off -c shared_buffers=1GB
docker compose up -d
docker compose restart web

JIT compilation (jit)

Catodon turns this off by itself, on every connection. There is nothing to do here. This section explains what it does and how to change it back.

When PostgreSQL estimates a query to be expensive, it compiles it to machine code before running it. That pays off for long reporting queries, not for Catodon's: they are short but touch many tables and columns, which makes them slow to compile. And the decision rests on the estimate alone, so a query the planner overestimates gets compiled even if it would have finished in milliseconds. In one measured case a forum query spent 15.7 seconds compiling for 0.2 seconds of actual work, well past Catodon's 10-second query limit; on a 2.9-million-post database the Home timeline's Hot sort spent 14.8 seconds compiling for 160 milliseconds of work.

Because of that, Catodon opens every database connection with -c jit=off, including connections to read replicas. This needs no configuration and no PostgreSQL restart.

To keep JIT on anyway, name it in the options connection parameter under the db section of .config/default.yml:

db:
  extra:
    options: '-c jit=on'

Anything else you put in options is kept and -c jit=off is added to it, so an unrelated setting there cannot switch JIT back on by accident; Catodon steps aside only when options mentions jit itself, in any capitalisation.

Behind a connection pooler, options may not be usable at all: PgBouncer rejects it as a startup parameter unless it is listed in ignore_startup_parameters (ignore_startup_parameters = options), and RDS Proxy rejects it with no equivalent setting. Set options to an empty string to stop Catodon sending the parameter at all, and turn JIT off on the server instead:

db:
  extra:
    options: ''   # send no options parameter; set jit on the server instead
sudo -u postgres psql -c "ALTER DATABASE <name> SET jit = off;"

In this case Catodon is relying on that server-level setting rather than on its own flag, and the setting only applies to sessions opened after it — so restart Catodon once after running it, or its existing pooled connections keep JIT on.

The server-level setting is also worth adding on a large instance even when Catodon's own connections do carry the flag, so that psql sessions and other tools, which do not go through Catodon, are not compiled either. Catodon's migrations do go through it and already get -c jit=off. Used that way it changes nothing about Catodon's own connections, so it needs no restart — just open a new psql session before testing it.

Replace <name> with the database name from the db section of .config/default.yml. Other databases on the same server keep their setting. With Docker, the shipped compose file already starts PostgreSQL with -c jit=off, which covers the whole server.

Known Issues

  • After first run, if you are not using S3/object storage, you may need to fix file upload permissions:
docker compose exec --user=root web chown -R 991:991 /catodon/files

Podman Users

Since Podman does not assume a default registry, unqualified image names like postgres can fail to pull. To fix this, add the following to /etc/containers/registries.conf:

unqualified-search-registries = ["docker.io", "quay.io"]