Skip to content

Upgrades, migrations and backups

Upgrading Plainwright means pulling new code and rebuilding. The database schema updates itself: the backend runs the Alembic migrations every time it starts. This page explains how that works, how to back up and restore a Docker Compose install, how to roll back, and how to rotate SECRET_KEY without losing stored credentials. Commands assume the repository folder on the Docker host; on a hosted platform, use its own backups and deploys.

What Where (Docker Compose) Why it matters
The database postgres_data volume Users, projects, tests, run history, encrypted secrets.
.env repository folder SECRET_KEY decrypts every stored provider key and secret. A database restored without it has unreadable secrets.
Artifacts screenshots volume (local storage) or your bucket Screenshots, visual baselines, diffs and evidence. Visual tests need their baselines.
Project test files ./test-files folder Files users uploaded for browser tests.
  1. Dump the database in Postgres’s compressed custom format, then copy the dump out of the container. Writing the dump inside the container and copying it avoids shell redirection, which corrupts binary output in Windows PowerShell.

    Terminal window
    docker compose exec -T postgres pg_dump -U testai -d testai_db -Fc -f /tmp/plainwright.dump
    docker compose cp postgres:/tmp/plainwright.dump ./plainwright.dump
    docker compose exec -T postgres rm /tmp/plainwright.dump
  2. Copy the artifacts and uploads (skip the first line when artifacts are in a bucket).

    Terminal window
    docker compose cp backend:/app/screenshots ./screenshots-backup
    cp -r ./test-files ./test-files-backup # PowerShell: Copy-Item -Recurse .\test-files .\test-files-backup
  3. Copy .env together with the dump, and store all of it off the machine.

A nightly backup on a Linux host can be a cron job running a small script:

/usr/local/bin/plainwright-backup.sh
#!/usr/bin/env bash
set -euo pipefail
cd /opt/plainwright # the repository folder
stamp="$(date +%F)"
mkdir -p backups
docker compose exec -T postgres pg_dump -U testai -d testai_db -Fc -f /tmp/plainwright.dump
docker compose cp postgres:/tmp/plainwright.dump "backups/plainwright-$stamp.dump"
docker compose exec -T postgres rm /tmp/plainwright.dump
find backups -name 'plainwright-*.dump' -mtime +14 -delete # keep two weeks
crontab -e
30 2 * * * /usr/local/bin/plainwright-backup.sh >> /var/log/plainwright-backup.log 2>&1

Then copy backups/ somewhere else, for example with rclone or your provider’s snapshots. On managed Postgres (Render, Railway, Fly Managed Postgres, RDS and others), turn on the provider’s backups or point-in-time recovery as well.

  1. Back up as above: the database dump and .env at least.

  2. Read the release notes in the Changelog for anything that needs action.

  3. Pull the new code, rebuild and restart.

    Terminal window
    git pull
    docker compose up -d --build

    The order is handled for you: the backend migrates the database before it reports healthy, and the workers and scheduler only start once it has.

  4. Check the result.

    Terminal window
    docker compose ps # backend "(healthy)", workers and scheduler "Up"
    docker compose logs backend # look for "database schema is at head"

Always rebuild the backend, worker and scheduler together: they share one code base. On hosted platforms, deploy the backend first and the workers and scheduler after it.

Migrations live in backend/migrations/versions and run through app/core/migrate.py when the backend starts. Workers and the scheduler never migrate. On start, the backend handles three cases:

Database state What happens
Empty alembic upgrade head builds the schema.
Already managed by Alembic alembic upgrade head applies anything new (nothing, if it’s current).
Built by an older release without Alembic (it has the app’s tables but no alembic_version) Stamped at 0001_baseline, then upgraded. The next migration also adds columns and enum values that older installs may be missing.

All of it runs in one transaction under a Postgres advisory lock. If several backends start at once, the others wait and then find nothing to do. If a migration fails, the transaction rolls back completely and the backend doesn’t start; fix the cause (usually in the log) and start it again.

Inspect the state by hand from the backend container:

Terminal window
docker compose exec backend alembic current # the revision the database is at
docker compose exec backend alembic history # every revision in this release

The database, user and enum names stay testai from the project’s earlier name; that’s expected, and it keeps existing volumes working.

  1. Stop the services that write to the database.

    Terminal window
    docker compose stop backend worker scheduler
  2. Copy the dump in and restore it. --clean --if-exists drops the current objects first.

    Terminal window
    docker compose cp ./plainwright.dump postgres:/tmp/plainwright.dump
    docker compose exec -T postgres pg_restore -U testai -d testai_db --clean --if-exists /tmp/plainwright.dump
  3. Restore .env (the same SECRET_KEY), the artifacts and the test files, then start everything.

    Terminal window
    docker compose cp ./screenshots-backup/. backend:/app/screenshots
    docker compose up -d

To restore onto a fresh machine, start only the database first with docker compose up -d postgres, restore, then start the rest. The backend migrates the restored database to the current release on start.

The safe way back from a bad upgrade is the backup you took before it:

  1. Check out the previous version: git checkout <previous-tag-or-commit>.
  2. Restore the database dump from before the upgrade, as above.
  3. Rebuild and start: docker compose up -d --build.

Alembic downgrade scripts exist, but downgrading the current release drops the tables it added, with their data. Prefer restoring a backup.

SECRET_KEY signs sign-in tokens and is the source of the key that encrypts stored provider keys, vault secrets, saved logins and integration credentials. Rotate it when the install still uses the built-in default (the app then shows owners and admins a warning banner), when the key may have leaked, or as routine hygiene.

Never just edit the variable: values encrypted with the old key would become unreadable. The rotation command decrypts every stored value with the current SECRET_KEY and re-encrypts it with the new one, in a single database transaction: either everything is rotated or nothing is. It also encrypts provider keys that older releases stored in plain text, and it prints only counts, never values.

python -m app.cli rotate-secret-key --new-key-env NEW_SECRET_KEY [--dry-run] [--allow-undecryptable]
Option What it does
--new-key-env VAR Required. The name of the environment variable that holds the new key, so the key itself never appears on the command line. The new key must be at least 32 characters, not a known default, and different from the current one.
--dry-run Decrypts and counts everything, then rolls back. Nothing is changed.
--allow-undecryptable Leaves values that fit neither key as they are instead of refusing. Use it only when you know those values are lost.

It re-encrypts the provider API keys (ai_configs.api_key), vault secrets (secrets.value_encrypted), integration configs (integrations.config_encrypted) and saved logins (auth_states.storage_state_encrypted), and prints one line of counts per column:

Rotation committed. Counts per column:
ai_configs.api_key rows=2 re-encrypted=1 plaintext-encrypted=1 already-new=0 undecryptable=0
secrets.value_encrypted rows=0 re-encrypted=0 plaintext-encrypted=0 already-new=0 undecryptable=0
integrations.config_encrypted rows=0 re-encrypted=0 plaintext-encrypted=0 already-new=0 undecryptable=0
auth_states.storage_state_encrypted rows=0 re-encrypted=0 plaintext-encrypted=0 already-new=0 undecryptable=0
Next: set SECRET_KEY to the new key for backend, worker AND scheduler, then restart all three together. Everyone has to sign in again.
  • plaintext-encrypted counts provider keys that an older release stored unencrypted.
  • already-new counts values that are already under the new key, so running the command twice is harmless.
  • If any value fits neither key, the command prints REFUSED: … can't be decrypted with the current SECRET_KEY (nor the new one). Nothing was changed. and exits with code 2. That usually means the container isn’t running with the key the data was written with. A new key that’s too short or a known default is refused with exit code 1.

On Docker Compose:

  1. Back up the database and .env, as above.

  2. Generate the new key into the NEW_SECRET_KEY variable of your shell.

    Terminal window
    export NEW_SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_urlsafe(48))')"
  3. Stop the services that use the key, so nothing is saved or decrypted with the old key half-way through. Postgres keeps running.

    Terminal window
    docker compose stop backend worker scheduler
  4. Do a dry run. It runs in a one-off backend container, which still has the current key from .env, and reports how many values it would re-encrypt without writing anything. Check that undecryptable is 0 on every line.

    Terminal window
    docker compose run --rm --no-deps -e NEW_SECRET_KEY backend python -m app.cli rotate-secret-key --new-key-env NEW_SECRET_KEY --dry-run
  5. Rotate for real with the same command, without --dry-run.

    Terminal window
    docker compose run --rm --no-deps -e NEW_SECRET_KEY backend python -m app.cli rotate-secret-key --new-key-env NEW_SECRET_KEY
  6. Put the new key in .env as SECRET_KEY, and set PLAINWRIGHT_ENV=production if this install is used by other people. Keep a copy of the new .env with your backups, and keep the old one with the dumps taken before today: those dumps still need the old key.

  7. Start everything with the new key. up -d recreates the backend, workers and scheduler together, so they all get the same value.

    Terminal window
    docker compose up -d

Everyone is signed out, because their tokens were signed with the old key; they just sign in again. Stored credentials keep working, and nothing has to be re-entered.

On a hosted platform the order is the same: scale the workers and the scheduler to zero, open a shell on the backend service (where SECRET_KEY still holds the current value), set NEW_SECRET_KEY there, run the command with --dry-run and then without it, set SECRET_KEY to the new value on every service (Render’s environment group, Railway’s shared variable, or fly secrets set on all three apps), and redeploy them together.