8 min read

This is the upgrade from 2.1.x to 2.2.0 for a deployment you run yourself with Docker. Four things need your attention: the cache container changes image and volume, three proxy secrets become required, the admin IP allowlist starts refusing addresses it cannot verify, and several migrations hold locks on tables that sign-in uses. Work through it in order and you will not need the changelog.

On Rekey Cloud there is nothing to do

Rekey Cloud already runs 2.2.0. For a deployment you run yourself, the 2.2.0 source, images and npm packages are published with the v2.2.0 release tag; until you see it on GitHub and npm, stay on 2.1.x and keep this page for when it lands.

Migrations self-apply

The api container runs prisma migrate deploy on start. There is no separate migration step to hold back, so by the time you notice a migration, it has run. Read this page before you redeploy, not after.

Valkey replaces Redis, on a new volume

The redis service in every compose file now runs valkey/valkey:8.1-alpine instead of redis:7-alpine. The service name, the REDIS_URL and REDIS_PASSWORD variables and the redis:// scheme are all unchanged. Valkey speaks the same wire protocol, so no application code changed. The swap is about licensing: the floating redis:7-alpine tag now resolves to Redis 7.4, which is source-available rather than open source.

The part that affects you is the volume. Valkey 8.x refuses to start on an AOF or RDB file written by Redis 7.4, and most existing self-hosted volumes are already in that format. Rather than turn the image swap into a boot-time outage, the compose files mount Valkey on a new, empty volume: rekey_valkey in docker-compose.yml and docker-compose.prod.yml, and valkey-data in docker-compose.api.yml. The old volume is declared nowhere, so Docker never touches it.

Starting empty is safe because this store is a cache, a queue and a lockout store, never a system of record. On first boot after the upgrade you lose exactly four things:

  • In-flight webhook delivery retries. The delayed BullMQ jobs disappear. Postgres is the source of truth for deliveries and a periodic poller re-attempts any pending row whose next attempt time has passed, so deliveries go out later rather than never.
  • Every active account lockout. An account mid lockout gets a clean slate, and the next bad attempt counts from zero.
  • Current rate-limit windows. Limits reset to zero for a moment.
  • In-flight PKCE state for OAuth and OIDC sign-ins. Anyone mid flow when you restart retries from the start.

Nothing in Postgres is affected. Once the deploy is healthy on the new volume, remove the old one by hand. Compose never deletes a volume it no longer declares, so it sits there unused until you do.

after you are happy
docker volume rm rekey_redis   # or redis-data, for docker-compose.api.yml

The three proxy secrets you must set first

docker-compose.prod.yml now requires PANEL_PROXY_SECRET, API_PROXY_SECRET and PORTAL_PROXY_SECRET. All three use the fail-fast form with no fallback, so compose refuses to render the file while any of them is unset or empty. Generate a separate value for each:

append to .env
printf 'PANEL_PROXY_SECRET=%s\nAPI_PROXY_SECRET=%s\nPORTAL_PROXY_SECRET=%s\n' \
  "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" >> .env

These are how the panel, the API and the portal recognise the Traefik in front of them. Each Traefik router carries a header middleware that sets X-Rekey-Proxy-Secret from the matching value, which also overwrites any copy a client sent. Only then is the forwarded client address believed. That address is what every per-IP limit counts, so without the secret the limits count a container instead of a visitor.

They are required rather than defaulted because the old behaviour was quiet. An empty value used to be accepted, and it turned per-IP protection off for public traffic with one line in the boot log as the only sign. If you leave one unset now, the deploy fails with a message naming the variable.

If you run the split units

The three split compose files require their own secrets the same way:

FileRequired from 2.2.0
docker-compose.api.ymlAPI_PROXY_SECRET, INTERNAL_CALLER_SECRET
docker-compose.panel.ymlPANEL_PROXY_SECRET, INTERNAL_CALLER_SECRET
docker-compose.portal.ymlPORTAL_PROXY_SECRET, INTERNAL_CALLER_SECRET

INTERNAL_CALLER_SECRET must be one value shared by all three units. Generate it once with openssl rand -hex 32 and paste the same string into each. The panel and portal send it as X-Rekey-Caller-Secret alongside X-Rekey-Client-Ip, the single visitor address they have validated themselves, and on that path the API reads only that header. Without a matching secret the client IP header is ignored and stripped, and every operator and portal visitor counts as the host's outgoing address.

docker-compose.yml, the development stack, still starts with none of these set. It requires POSTGRES_PASSWORD, REDIS_PASSWORD and ENCRYPTION_KEY, as it did before.

The admin allowlist now refuses what it cannot verify

With ADMIN_IP_ALLOWLIST set, a request to /api/v1/admin/* that arrives through a proxy the API cannot identify is answered 403 ADMIN_IP_UNVERIFIABLE before the key is read.

The reason is that the allowlist checks the decided client address, and behind an unidentified proxy there is no such address. What the API sees is the proxy, shared by everyone behind it, so an allowlist naming that address would admit all of them. Refusing is the safer answer. The API also logs this once at boot when the allowlist is set and API_PROXY_SECRET is not.

The fix is one of two things: set API_PROXY_SECRET and have the proxy send it as X-Rekey-Proxy-Secret on every request, or clear the allowlist. If you already set the three secrets above, the Traefik label in the compose file does the sending for you.

If you lock yourself out

Unset ADMIN_IP_ALLOWLIST on the API unit and redeploy or restart it. Admin calls then need only SUPER_ADMIN_KEY. Fix whatever failed, confirm from a request log that the API records your real address rather than the proxy's, then set the allowlist again and redeploy. A 403 naming ADMIN_IP_NOT_ALLOWED from an address that is on the list is the same symptom with the same cause.

One Traefik detail worth checking on the way past. The API domain has to come from the compose labels, because that is the router carrying the proxy-secret middleware. A domain added by hand in the Dokploy UI creates a second router without it. Remove that domain.

Migrations, and the downtime to expect

Most migrations in this release add a column with a constant default or a new table, and finish instantly. A handful rewrite existing rows or build indexes, and hold locks while they do. The full table of which migration blocks what is in DEPLOY.md, under “Upgrading: 2.2.0 migrations and rollback”. Read it before you deploy. The shape of it is:

  • Sign-in and refresh stop, briefly. Two migrations backfill and then require a session id on both refresh-token tables, and a third adds a device foreign key to refresh_tokens. End-users and operators cannot sign in or refresh while those run.
  • Writes to log tables stop, briefly. Backfills and index builds block inserts into security_events, email_logs and webhook_deliveries, and one column add waits for a lock on api_request_logs, which every request writes to.
  • Five dashboard indexes block writes while they build. On security_events, subscriptions, end_users, api_request_logs and webhook_deliveries. Reads are not blocked.

The cost depends entirely on how big those tables are, so count first:

before you deploy
SELECT 'license_activations' AS t, count(*) FROM license_activations
UNION ALL SELECT 'refresh_tokens', count(*) FROM refresh_tokens
UNION ALL SELECT 'tenant_refresh_tokens', count(*) FROM tenant_refresh_tokens
UNION ALL SELECT 'security_events', count(*) FROM security_events
UNION ALL SELECT 'email_logs', count(*) FROM email_logs
UNION ALL SELECT 'webhook_deliveries', count(*) FROM webhook_deliveries;

With under roughly 100,000 rows in each, deploy normally and it is seconds. Above that, plan a short maintenance window.

Building the five indexes by hand

prisma migrate deploy cannot build indexes CONCURRENTLY, because it sends the migration file as one batch that Postgres runs as a transaction. If any of those five tables is past roughly 100,000 rows, build them yourself first, without blocking writes. The migration uses IF NOT EXISTS, so it skips every index you already built and nothing needs marking as applied. The exact five CREATE INDEX CONCURRENTLY statements are in DEPLOY.md under “Building the five dashboard indexes by hand”. Run them one at a time, outside a transaction. If one fails part way it leaves an invalid index behind, which you drop CONCURRENTLY before running that line again.

Rolling back needs one SQL step first

Three new columns are NOT NULL with no default, and the 2.1.x API never writes them. Before you start a 2.1.x image against a migrated database, run:

before starting a 2.1.x image
ALTER TABLE refresh_tokens ALTER COLUMN session_id DROP NOT NULL;
ALTER TABLE tenant_refresh_tokens ALTER COLUMN session_id DROP NOT NULL;
ALTER TABLE license_activations ALTER COLUMN application_id DROP NOT NULL;

Without the first two, every sign-in and refresh fails on the old API. Without the third, activating a licence on a new machine fails. Everything else in the new schema is ignored by the old code, and the five dashboard indexes need nothing. Going forward to 2.2.0 a second time needs the reverse: fill in what the old API left empty and restore the constraints, because prisma migrate deploy will not re-run a migration it has already recorded. Those statements are in the same DEPLOY.md section.

If your deploy overlaps containers

If the old api container keeps serving while the new one runs migrations, the old container cannot create refresh tokens once session_id is NOT NULL, so sign-ins and refreshes fail until the new container takes over. A failed refresh does not spend the token, so clients recover on their next attempt.

The checklist

Work through this in order. It assumes the bundled postgres service from docker-compose.prod.yml.

  1. Take a dump. The -T matters, because without it compose allocates a TTY and the dump lands corrupted.
    docker compose -f docker-compose.prod.yml exec -T postgres \
      pg_dump -U rekey -d rekey -Fc > rekey-$(date +%F).dump
  2. Count the rows with the query above. Under 100,000 each, carry on. Above that, book a window and build the five indexes by hand first, following DEPLOY.md.
  3. Generate the three proxy secrets into .env with the printf command above. On the split units, set each file's own secrets plus one shared INTERNAL_CALLER_SECRET.
  4. Check your admin allowlist. If ADMIN_IP_ALLOWLIST is set, confirm API_PROXY_SECRET is set too, or clear the allowlist for now.
  5. Deploy. Migrations run on api container start.
    docker compose -f docker-compose.prod.yml up -d
  6. Check health. It is dependency-aware and returns 503 naming the unreachable dependency, so a wrong credential shows up here rather than silently.
    curl -sf localhost:3030/health
  7. Read the boot log. Grep for [SECURITY] and for any warning about API_PROXY_SECRET. Sign in at the panel and confirm the API logs your real address, not Traefik's.
  8. Remove the old cache volume once you are satisfied, with the docker volume rm command above. There is no rush, and nothing reads it in the meantime.

The rest of the release

Every other new setting in 2.2.0 is optional, and leaving it unset keeps the previous behaviour. Two behaviour changes are worth knowing about before an operator reports them as a bug: erasing or cascade-deleting an end-user is now workspace-owner only, so an admin who handles erasure requests gets a 403 naming the owner and needs an owner to act, though the data export still works for them. And entitlement resolution treats an Application's free tier as a base layer rather than another voter, which changes what three existing populations resolve. The Breaking changes section of CHANGELOG.md names which subjects to check.

If you are setting a deployment up rather than upgrading one, the self-hosting guide is the place to start, and DEPLOY.md is the reference for both.

Upgrading a self-hosted Rekey from 2.1.x to 2.2.0 | Rekey