Upgrade from 0.13 to 0.14

Upgrade a self-hosted Quackback 0.13 install to 0.14: back up, remove Redis, move to Silo storage, set trusted proxy hops, keep one email provider, and see what changes.

JM
James Morton
Written By James MortonLast updated about 2 hours ago

How to upgrade a self-hosted Quackback 0.13 install to 0.14, and what changes for operators, admins, and developers. 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. Quackback Cloud workspaces are upgraded for you.

Warning: 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 pg_cron extension is no longer needed.
  • 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 (for example 0.14.0), then make these .env changes.

Warning: 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 Configure one email provider.

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.

Warning: 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.0 /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. See Health endpoints.

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 up a reverse proxy.

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 Configure email delivery.

Telemetry

The anonymous daily snapshot now reports usage in bands (such as 11-50). 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.13

0.14

votes

post_votes

comments

post_comments

tags

post_tags

post_tags (post-to-tag join)

post_tag_assignments

comment_reactions

post_comment_reactions

merge_suggestions

post_merge_suggestions

chat_messages

conversation_messages

chat_tags

conversation_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 Understand 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 authorize it again. See Slack.

Imports

The dedicated importers for other feedback tools were removed. CSV import remains in Admin > Settings > Imports & exports. See Import and export data.

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 (see the table below). 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 Understand roles and permissions.
  • Webhooks and events cover more of the product. See Webhooks for the full catalogue.

Renamed ID prefixes

Old prefix

Current 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_

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.

Was this helpful?

Your feedback shapes what we write next.