Skip to content

Upgrade from 0.13 to 0.14

0.14 is a large release. Redis is gone, the bundled object storage changed, and the first start runs around 140 database migrations. Read this page before you upgrade, and plan a short maintenance window.

There is no rolling upgrade and no downgrade. Migrations can't be reversed, so the only way back is restoring the backup you take in step 1. Never run 0.13 and 0.14 against the same database.

Before you start

Check that your setup meets the 0.14 requirements:

  • PostgreSQL 14 or newer, with pgvector 0.5.0 or newer and the pg_trgm extension available
  • The database user has the TEMPORARY privilege (PostgreSQL grants it by default)
  • DATABASE_URL is a direct or session-mode connection, not a transaction-mode pooler (for example a pooler on port 6543). Realtime uses LISTEN/NOTIFY, which a transaction pooler drops.
  • SECRET_KEY is set to the value your 0.13 install used, and is at least 32 characters

If something is missing, the migrator stops before changing anything and lists each problem with its fix.

Upgrade with Docker Compose

1. Stop the app and back up

With your 0.13 files still in place, stop the app so nothing writes. Don't use -v: the volumes must stay.

docker compose -f docker-compose.prod.yml stop app

Back up your configuration, PostgreSQL, and object storage into a directory outside the repository. The bundled storage server changes in this release (see Bundled object storage is now Silo), so take an offline copy of the whole storage volume:

umask 077
BACKUP="../quackback-0.13-backup-$(date -u +%Y%m%dT%H%M%SZ)"
mkdir "$BACKUP"
cp .env "$BACKUP/environment.env"
cp docker-compose.prod.yml "$BACKUP/compose-before.yml"
# Save the current storage server image, needed for a rollback
docker image save "$(docker inspect quackback-minio --format '{{.Image}}')" \
  > "$BACKUP/server-image.tar"
 
# Keep PostgreSQL running; stop writers first (stopping a stopped service is harmless)
docker compose -f docker-compose.prod.yml stop app minio
docker compose -f docker-compose.prod.yml exec -T postgres \
  sh -c 'exec pg_dump -Fc -U "$POSTGRES_USER" "$POSTGRES_DB"' > "$BACKUP/database.dump"
docker cp quackback-minio:/data/. - > "$BACKUP/data.tar"
tar -tf "$BACKUP/data.tar" > /dev/null

Keep the backup private: the storage archive includes credentials.

Then stop the rest of the stack, still without -v:

docker compose -f docker-compose.prod.yml down

2. Update the files

Pull the new source with git pull. If you don't deploy from a git checkout, download the new docker-compose.prod.yml, .env.prod.example, and the docker/postgres/ directory instead. Set QUACKBACK_TAG in .env to the 0.14 release, then make these .env changes.

Finish every .env change before you start 0.14. Some settings, such as the one-email-provider rule, are only checked after the migrations have committed, so a mistake there stops the app on an already-migrated database.

  • If .env has a REDIS_URL line, delete it. Redis and Dragonfly are no longer used. The 0.13 compose file set it for you, so most installs don't have one.
  • If you set MINIO_IMAGE_TAG or MC_IMAGE_TAG, remove them. They're ignored now.
  • Set SECRET_KEY to the value your 0.13 install used. The compose file refuses to start without it.
  • Set TRUSTED_PROXY_HOPS. Behind Nginx, Caddy, Traefik, or a Cloudflare Tunnel, set 1. Behind a CDN plus a proxy, set 2. If clients connect straight to the app port, keep 0. If your proxy sets a single client-IP header such as X-Real-IP, you can set TRUSTED_CLIENT_IP_HEADER instead. See Trusted proxy hops.
  • Keep only one email provider: EMAIL_SMTP_HOST, the EMAIL_SES_* keys, or EMAIL_RESEND_API_KEY. Check this now, because the app stops after migrations if more than one is set. See Email.

3. Pull the new image

docker compose -f docker-compose.prod.yml pull

4. Start once and wait

docker compose -f docker-compose.prod.yml up -d --remove-orphans
docker compose -f docker-compose.prod.yml logs -f app

The first start runs about 140 migrations in one transaction and logs each one as [n/total]. On a large database this can take several minutes.

Don't stop or restart the container while migrations run. An interrupted run rolls back completely and starts over on the next start.

After the migrations commit, the app builds its search indexes, then starts serving. Wait for the healthcheck (/api/health/ready) to pass.

In the background, files uploaded before 0.14 are copied into the new storage layout. The originals are kept, and links keep working while the copy runs.

5. Check and clean up

  1. Sign in, open an existing post with an attachment, and upload a new file.

  2. Remove the unused Dragonfly volume. The project name defaults to the directory name, so look it up first:

    docker volume ls | grep dragonfly
    docker volume rm <project>_dragonfly_data
  3. Keep your backups until you're happy with the upgrade.

Upgrade other installs

The same order applies to every deployment: stop the old version, back up the database and object storage, change the configuration, start 0.14 once, and wait for migrations.

  • docker run or Kubernetes: remove REDIS_URL and your Redis or Dragonfly service, add TRUSTED_PROXY_HOPS, and run migrations once before starting replicas. With several replicas, set SKIP_MIGRATIONS=true on all of them and run the migrate-only container first:

    docker run --rm -e DATABASE_URL="..." --entrypoint bun \
      ghcr.io/quackbackio/quackback:<0.14 tag> /app/migrate.mjs

    Then make sure at least one replica runs with QUACKBACK_ROLE=worker or all. Background jobs now run from a queue in PostgreSQL. See Scale with multiple replicas.

  • Railway: delete the Redis service and the REDIS_URL variable, add TRUSTED_PROXY_HOPS=1 (2 if Railway's CDN is on), then deploy the 0.14 image. Give the health check a long timeout for the first start.

  • Without Docker: stop the service, back up, git pull, bun install, bun run db:migrate, bun run build, then start. Uninstall Redis or Dragonfly if nothing else uses it.

What changed for operators

Redis is gone

Background jobs, caches, rate limits, and realtime events now run on PostgreSQL. There is nothing to replace Redis with. Jobs run in the app's worker role (or the default all role). /api/health/ready now checks the database, migrations, and workers. /api/health and /api/health/live remain liveness checks.

Bundled object storage is now Silo

The Docker Compose stack runs PGSTY Silo, a maintained fork of MinIO, instead of MinIO. The minio service name, the minio_data volume, MINIO_ROOT_USER, MINIO_ROOT_PASSWORD, and the S3 endpoint stay the same, and Silo reads the existing data. Both images are pinned by digest. To use a mirror, set SILO_IMAGE and SILO_CLIENT_IMAGE to full image references.

If you use an external S3 provider, nothing changes here. S3_PUBLIC_URL no longer changes the URLs of new files: every file is served through /api/storage. Keep it set only if older content links to your public bucket or CDN URL.

Trusted proxy hops

Rate limits are per client IP. Behind a proxy, the app only sees the real IP if you tell it how many proxies to skip. Left at 0 behind a proxy, every visitor shares one rate-limit bucket and the app logs a warning. Never set it above 0 if clients can reach the app port directly, because anyone could then pick the IP the app sees. If your proxy sets one client-IP header (X-Real-IP from Nginx, CF-Connecting-IP from Cloudflare), you can set TRUSTED_CLIENT_IP_HEADER to its name instead. See Set the trusted proxy hops.

Configure one email provider

You can send through SMTP, Amazon SES (EMAIL_SES_ACCESS_KEY_ID, EMAIL_SES_SECRET_ACCESS_KEY, and EMAIL_SES_REGION, all required), or Resend (EMAIL_RESEND_API_KEY or RESEND_API_KEY). Configure exactly one. If more than one is set, the app refuses to start and names the variables.

The one exception: if you receive inbound email through Resend while SMTP or SES sends, keep the Resend key and set EMAIL_INBOUND_PROVIDER=resend. The key is then used only for receiving. See Email.

Telemetry

The anonymous daily snapshot now reports usage in bands (such as 11-50) and goes to the project's PostHog analytics. It never includes names, emails, URLs, hostnames, or content. Opt out with DISABLE_TELEMETRY=true. See Telemetry.

Renamed tables

If you run SQL reports against the database, update table names. The main renames:

0.130.14
votespost_votes
commentspost_comments
tagspost_tags
post_tags (post-to-tag join)post_tag_assignments
comment_reactionspost_comment_reactions
merge_suggestionspost_merge_suggestions
chat_messagesconversation_messages
chat_tagsconversation_tags
conversation_tags (conversation-to-tag join)conversation_tag_assignments

What changed for admins

Roles

Existing admins become Owners and existing members become Managers. Review roles in Admin → Settings → Members & Teams after the upgrade. See Roles and permissions.

Roadmaps follow statuses

Roadmaps are now built from post statuses, so you no longer place posts on a roadmap by hand. Old manual placements are kept in a post_roadmaps_archived table for reference until a later release. Nothing reads it. See Build a public roadmap.

Conversation status "pending" is now "snoozed"

Conversations that were pending are now snoozed. Conversations are open, snoozed, or closed.

SSO and OIDC redirect URIs

New sign-in providers use the redirect URI /api/auth/callback/<id>. Providers you added before 0.14, including the portal's custom OIDC provider, keep working with their original /api/auth/oauth2/callback/<id>, so nothing changes at your identity provider on upgrade. To move one to the new URI, register the new URI at your identity provider first, click Switch to new URI in the provider's Edit view in Quackback, then test the connection again. See Set up single sign-on.

Slack

The Slack app's request URLs changed to:

  • https://<your domain>/api/integrations/slack/hooks/events
  • https://<your domain>/api/integrations/slack/hooks/interactions
  • https://<your domain>/api/integrations/slack/hooks/commands
  • https://<your domain>/api/integrations/slack/hooks/options

Update them in your Slack app's settings, then reconnect Slack in Admin → Settings → Integrations to re-authorise it. See Slack.

Imports

The importers for other feedback tools were removed. CSV import remains in Admin → Settings → Imports & exports.

AI spam filter

With AI configured, Quackback can send new email and Messenger conversations to the model to file obvious spam. Workspaces upgrading from 0.13 start with this switched off; new workspaces start with it on. Turn it on with AI spam filter in Admin → Settings → Channels → Email. Filed spam is deleted after 30 days. Trusted senders and the sender checks that don't use AI work either way. See Set up the email channel.

Switched-off surfaces stay hidden

0.14 replaces the separate Help Center and Messenger on/off switches with the module toggles in Admin → Settings → General and the widget's Messages tab. If your Help Center or Messenger was switched off in 0.13, the upgrade keeps it off: the Help Center module or the Messages tab is turned off, so nothing goes public. Your articles and conversations stay. Turn them back on when you're ready.

What changed for developers

  • Widget identify needs a signed token. identify only accepts a signed ssoToken generated on your server. Unsigned { id, email, name } payloads are rejected. See Identify users.

  • Retired ID prefixes still work. Several ID prefixes were renamed. The API still accepts IDs you stored from 0.13 and returns them with the current prefix:

    Old prefixCurrent prefix
    status_post_status_
    tag_post_tag_
    comment_post_comment_
    vote_post_vote_
    reaction_post_comment_reaction_
    comment_edit_post_comment_edit_
    note_post_note_
    activity_post_activity_
    merge_sug_post_merge_sug_
    linked_entity_post_external_link_
    chat_msg_conversation_msg_
    chat_tag_conversation_tag_
    chat_msg_mention_conversation_msg_mention_
    category_kb_category_
    kb_article_article_
    article_feedback_kb_article_feedback_

    Store the current form when you next write IDs back.

  • Roadmap isPublic is deprecated. POST and PATCH /api/v1/roadmaps still accept it as an alias of visibility: true means public and false means team. If you send both, visibility wins. Use visibility (public, team, or segment) in new code.

  • Permission checks follow roles. /api/v1/apps/link and /api/v1/apps/unlink need the post.vote_on_behalf permission (Owner, Admin, Manager, and Contributor have it), so sidebar apps installed with a Manager's key keep working. /api/export (posts CSV) needs post.export (Owner, Admin, and Manager). See Roles and permissions.

  • Webhooks and events cover more of the product. See Webhooks for the full catalogue.

If something goes wrong

  • Migrations stop with a list of problems: fix each item (PostgreSQL version, pgvector, pg_trgm, TEMPORARY) and start again. Nothing was changed.
  • A migration fails: the log names it, and the whole run is rolled back. Check the error, then start again. If you're stuck, restore the backup and run 0.13 while you ask for help on GitHub.
  • The app exits naming email variables: you have more than one email provider configured. Keep one.
  • Realtime updates don't arrive: DATABASE_URL points at a transaction-mode pooler. Use a direct or session-mode connection.

Roll back

Migrations can't be undone, so rolling back means restoring the backup from step 1 into fresh volumes and running 0.13 again. This sequence deletes the current volumes, so run it only when you mean to roll back. Uploads and credential changes made after the backup are lost.

Replace 0.13.2 with the exact 0.13 release you ran. In a new shell, set BACKUP to your backup directory first, for example BACKUP=../quackback-0.13-backup-20261005T120000Z.

docker compose -f docker-compose.prod.yml down
docker volume ls | grep -E 'postgres_data|minio_data'   # find <project>
docker volume rm <project>_postgres_data <project>_minio_data
cp "$BACKUP/compose-before.yml" docker-compose.prod.yml
cp "$BACKUP/environment.env" .env
# Pin the exact release you're returning to. A restored QUACKBACK_TAG=latest would
# start the 0.14 image you already pulled and migrate the database forward again.
sed -i 's/^QUACKBACK_TAG=.*/QUACKBACK_TAG=0.13.2/' .env
docker image load < "$BACKUP/server-image.tar"
docker compose -f docker-compose.prod.yml create
docker compose -f docker-compose.prod.yml up -d --wait postgres
# "already exists" errors for the vector and pg_cron extensions are harmless.
docker compose -f docker-compose.prod.yml exec -T postgres \
  sh -c 'pg_restore --no-owner -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
  < "$BACKUP/database.dump"
docker cp - quackback-minio:/data < "$BACKUP/data.tar"
docker compose -f docker-compose.prod.yml up -d

Never run the 0.13 image against a database 0.14 has migrated.

Next steps