> ## Documentation Index
> Fetch the complete documentation index at: https://docs.casebender.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Migrate a Legacy Docker Compose Installation

> Move an existing app/db/MinIO installation to the signed CaseBender release bundle without losing data, attachments, credentials, or audit integrity.

# Migrate a legacy Docker Compose installation

Use this guide when the existing installation has a `docker-compose.yml` with
services such as `app`, `db`, and `minio`, or when it has historically been
updated with `docker compose pull`.

This is a one-time layout migration. It is not the same as a routine upgrade of
an installation that already uses `docker-compose.prod.yml` and
`./casebender upgrade`.

> Do not continue without a tested PostgreSQL restore and a verified attachment
> backup. Never run `./casebender init`, replace the existing `.env`, rotate an
> existing encryption or audit key, or run `docker compose down -v`.

## What changes

| Area               | Legacy installation          | Signed bundle                                                   |
| ------------------ | ---------------------------- | --------------------------------------------------------------- |
| Compose file       | `docker-compose.yml`         | `docker-compose.prod.yml`                                       |
| Web service        | `app`                        | `web`                                                           |
| PostgreSQL service | `db`                         | `postgres`                                                      |
| Image selection    | Often `latest`               | Version pinned by `release.env`                                 |
| Upgrade command    | Often raw Compose commands   | `./casebender upgrade`                                          |
| Attachments        | Usually MinIO in `miniodata` | Local `/data` in `casebender_data`, or an external object store |
| License key        | Often `casebender_secret`    | `LICENSE_SECRET_KEY` in `.env`                                  |
| Audit integrity    | May be absent                | Stable `AUDIT_INTEGRITY_SECRET` in `.env`                       |

Docker Compose prefixes named volumes with the Compose project name. Keeping
the same installation directory and project name normally lets the signed
bundle reuse `pgdata` and `redis_data`. A different directory or `-p` value
creates different volume names and can make the application appear empty even
though the original data still exists.

## Phase 1: Inventory the existing installation

Run these commands from the existing installation directory:

```bash theme={null}
pwd
docker compose ls
docker compose config --services
docker compose config --volumes
docker compose ps
docker volume ls
```

Record:

* the Compose project name and installation directory;
* the exact CaseBender image tags;
* the PostgreSQL image and major version;
* the actual volume names mounted at `/var/lib/postgresql/data`,
  `/data`, and `/app/apps/web/app/secret`;
* whether attachments use MinIO, local storage, or an external provider;
* the current database user, database name, and internal hostname;
* the current TLS and reverse-proxy configuration.

Inspect mounts without printing environment secrets:

```bash theme={null}
docker inspect "$(docker compose ps -q db)" \
  --format '{{range .Mounts}}{{println .Name "->" .Destination}}{{end}}'
docker inspect "$(docker compose ps -q app)" \
  --format '{{range .Mounts}}{{println .Name "->" .Destination}}{{end}}'
```

Check required keys by name only:

```bash theme={null}
for key in \
  AUTH_SECRET AUTH_SALT NEXTAUTH_SECRET LICENSE_SECRET_KEY \
  FIELD_ENCRYPTION_KEY CREDENTIAL_ENCRYPTION_KEY \
  WEBHOOK_KEY_PEPPER CONNECTOR_BUNDLE_SIGNING_KEY OAUTH_BROKER_SECRET \
  AUDIT_INTEGRITY_SECRET POSTGRES_PASSWORD REDIS_PASSWORD; do
  if grep -q "^${key}=." .env; then
    printf '%s: configured\n' "$key"
  else
    printf '%s: missing\n' "$key"
  fi
done
```

Do not paste `.env`, license keys, encryption keys, database URLs, or backup
contents into tickets or chat.

## Phase 2: Create and test the recovery set

### PostgreSQL

Create a logical backup using the legacy `db` service:

```bash theme={null}
mkdir -p migration-backup
chmod 700 migration-backup

docker compose exec -T db \
  sh -c 'pg_dump -Fc -U "$POSTGRES_USER" "${POSTGRES_DB:-casebender}"' \
  > "migration-backup/casebender-$(date +%F).dump"

test -s "migration-backup/casebender-$(date +%F).dump"
docker run --rm -i postgres:17 pg_restore --list \
  < "migration-backup/casebender-$(date +%F).dump" >/dev/null
```

The final proof is a restore into an isolated staging database followed by
application validation. Listing the archive is not a restore rehearsal.

Record baseline counts for users, organizations, cases, alerts, tasks,
attachments, evidence, and audit records. Use approved read-only queries for
the deployed schema.

### Configuration, TLS, and secrets

```bash theme={null}
cp -p .env "migration-backup/.env.$(date +%F)"
cp -p docker-compose.yml "migration-backup/docker-compose.yml.$(date +%F)"
cp -p nginx.conf "migration-backup/nginx.conf.$(date +%F)" 2>/dev/null || true
```

Store the backup outside the Docker host in the approved encrypted recovery
system. Include:

* `.env`;
* TLS certificates, private keys, and custom trust stores;
* the exact license secret and license blob;
* field and credential encryption keys;
* webhook, connector-signing, OAuth, authentication, and audit secrets;
* integration configuration and proxy/egress settings;
* the current Compose file and image inventory.

If `LICENSE_SECRET_KEY` is absent from `.env`, preserve the existing value
before stopping `app`:

```bash theme={null}
umask 077
docker compose exec -T app \
  sh -c 'cat /app/apps/web/app/secret/license_secret_key' \
  > migration-backup/license_secret_key
test -s migration-backup/license_secret_key
```

Transfer that value into the protected `LICENSE_SECRET_KEY` entry during the
environment transformation. Do not print it.

### Attachments

If `STORAGE_PROVIDER=minio`, back up the bucket through the S3/MinIO API. A
tarball or direct copy of MinIO's internal `miniodata` layout is a disaster
recovery snapshot, not a valid local-storage migration.

Keep both:

1. a snapshot or archive of the original `miniodata` volume; and
2. an object-level export made with `mc mirror` or your approved S3 backup
   process.

Compare the exported object count and size with the source, then download
several known case attachments and evidence files from the rehearsal system.

## Phase 3: Check alert-promotion compatibility

The new release enables alert-to-case promotion by default. Keep it explicitly
disabled during a legacy-data migration:

```env theme={null}
ENTERPRISE_ALERT_PROMOTION=disabled
```

Check the schema and legacy observable parentage:

```sql theme={null}
SELECT to_regclass('"AlertPromotionOperation"') AS promotion_table;

SELECT count(*) AS dual_parent_observables
FROM "Observable"
WHERE "alertId" IS NOT NULL
  AND "caseId" IS NOT NULL;

SELECT EXISTS (
  SELECT 1
  FROM pg_constraint
  WHERE conname = 'Observable_at_most_one_parent_check'
) AS parent_constraint_installed;
```

Stop and use the release-specific promotion backfill procedure when:

* the promotion table is missing;
* any dual-parent observable exists; or
* backfill verification is not clean.

Do not enable promotion until the schema migration is applied, dual-parent
conflicts are zero, the parent constraint decision is verified, and the worker
outbox processor is healthy.

## Phase 4: Choose the attachment target

### Option A: Continue using an external MinIO or S3-compatible service

This minimizes application-level storage change. The object store must remain
reachable from the new `web` container. Set:

```env theme={null}
STORAGE_PROVIDER=minio
MINIO_ENDPOINT=minio.internal.example
MINIO_PORT=9000
MINIO_USE_SSL=true
MINIO_ACCESS_KEY=existing-access-key
MINIO_SECRET_KEY=existing-secret-key
MINIO_BUCKET=casebender
```

`MINIO_ENDPOINT` is a hostname, not a URL. Use a trusted TLS endpoint for
production. The signed bundle does not run an embedded MinIO container, so an
external endpoint must exist before cutover.

### Option B: Migrate MinIO objects to local `/data`

Create the target volume with the same Compose project name:

```bash theme={null}
PROJECT_NAME="$(docker compose ls --format json |
  node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const v=JSON.parse(s);console.log(v[0]?.Name??"")})')"
test -n "$PROJECT_NAME"
docker volume create "${PROJECT_NAME}_casebender_data"
```

Use `mc mirror` or an equivalent object-level export to write each bucket object
under the same object key in `casebender_data`. Do not copy MinIO's internal
volume files directly. Set:

```env theme={null}
STORAGE_PROVIDER=local
STORAGE_PATH=/data
MINIO_ENDPOINT=
```

Validate object counts, total bytes, and representative downloads in staging.
Retain the original `miniodata` backup through the rollback window.

## Phase 5: Prepare `.env` for the signed bundle

Start from the existing `.env`; do not start from `.env.example` and do not run
`./casebender init`.

| Variable                      | Migration action                                                                       |
| ----------------------------- | -------------------------------------------------------------------------------------- |
| `POSTGRES_USER`               | Preserve the existing user, commonly `superadmin`                                      |
| `POSTGRES_PASSWORD`           | Preserve exactly                                                                       |
| `POSTGRES_DB`                 | Preserve exactly                                                                       |
| `POSTGRES_PRISMA_URL`         | Change only hostname `db` to `postgres`                                                |
| `POSTGRES_URL`                | Change only hostname `db` to `postgres`                                                |
| `POSTGRES_URL_NON_POOLING`    | Change only hostname `db` to `postgres`                                                |
| `REDIS_PASSWORD`              | Add a strong installation-specific value if absent                                     |
| `REDIS_URL`                   | Use `redis://:<password>@redis:6379`                                                   |
| `OPENSEARCH_PASSWORD`         | Add a strong installation-specific value required by the production Compose definition |
| `LICENSE_SECRET_KEY`          | Preserve the value recovered from `.env` or `casebender_secret`                        |
| `AUDIT_INTEGRITY_SECRET`      | Preserve if present; otherwise generate once during managed preparation                |
| Encryption and signing keys   | Preserve existing values and legacy fallbacks                                          |
| `NEXTAUTH_URL`, `NEXTAPP_URL` | Preserve the customer URLs                                                             |
| `DEPLOYMENT_PROFILE`          | Set to `onprem` unless enterprise is licensed and prepared                             |
| `CASEBENDER_LEGACY_BOOTSTRAP` | Set to `false`                                                                         |
| `ENTERPRISE_ALERT_PROMOTION`  | Keep `disabled` until compatibility checks pass                                        |

The release's `./casebender upgrade` copies
`CASEBENDER_RELEASE_VERSION` and `CASEBENDER_REGISTRY` from the verified
`release.env`, and backfills missing audit and integration secrets. Never
replace an existing value during a routine migration.

Update `NO_PROXY` for the new internal names, including `postgres`, `redis`,
`web`, `api`, `worker`, and other enabled services.

## Phase 6: Rehearse the complete migration

Restore copies of PostgreSQL, attachments, `.env`, and secrets into an isolated
network. Use the same PostgreSQL major version as the source.

In rehearsal:

1. verify and extract the signed bundle;
2. preserve the intended Compose project name;
3. apply the `.env`, TLS, database-hostname, Redis, storage, and license changes;
4. run preflight;
5. start the pinned release;
6. verify migrations and service health;
7. compare baseline database and attachment counts;
8. validate login, cases, alerts, tasks, attachments, evidence, credentials,
   integrations, audit writes, and backups;
9. complete the alert-promotion backfill checks before enabling promotion;
10. record the actual recovery point and recovery time.

Do not connect the rehearsal environment to production integrations.

## Phase 7: Production cutover

1. Announce a maintenance window and stop inbound integrations and user writes.

2. Take fresh final PostgreSQL and attachment backups.

3. Preserve the legacy Compose file as `docker-compose.legacy.yml`.

4. Verify and copy the new bundle files into the same installation directory.

5. Preserve `.env` and apply the reviewed transformation.

6. Install trusted TLS files at:

   ```text theme={null}
   deploy/nginx/ssl/fullchain.pem
   deploy/nginx/ssl/privkey.pem
   ```

7. Stop the legacy stack without deleting volumes:

   ```bash theme={null}
   docker compose -f docker-compose.legacy.yml down
   ```

8. Run:

   ```bash theme={null}
   ./casebender preflight
   ./casebender upgrade
   ./casebender logs
   ```

9. Keep `ENTERPRISE_ALERT_PROMOTION=disabled` until post-migration schema and
   backfill verification passes. Then remove the override or set it to
   `enabled`, recreate the caller services, and test a non-critical promotion.

## Phase 8: Validate and close the migration

Confirm:

* every expected container is healthy;
* the deployment remains `ACTIVE` and existing users can sign in;
* baseline users, organizations, cases, alerts, tasks, audit records, and
  attachment counts match;
* representative attachments and evidence download correctly;
* stored integration credentials still decrypt and a safe connection test
  succeeds;
* Redis, worker queues, and the alert-promotion outbox are healthy;
* updating a non-critical alert creates an audit entry without an integrity
  error;
* creating and merging a non-critical alert into a case succeeds after
  promotion is enabled;
* the deployment can produce a new backup.

Run the release canary:

```bash theme={null}
node scripts/deployment/canary-check.mjs \
  https://casebender.your-company.example
```

Retain the legacy Compose file, previous image manifest, original `.env`,
database dump, object-store backup, and volume snapshots until the approved
rollback window closes.

## Rollback

Before starting the signed bundle, restart the legacy Compose file against the
untouched volumes if rehearsal or preparation fails.

After database migrations run:

* use `./casebender rollback --confirm-schema-compatible` only when the release
  notes explicitly permit image-only rollback;
* otherwise stop the new stack, restore the matching pre-migration PostgreSQL
  and attachment backups, restore the legacy `.env` and Compose file, and start
  the previous pinned images.

Never run an older application image against an unsupported newer schema.

After this one-time migration succeeds, follow
[Upgrading CaseBender](/en/deployment/upgrading) for future releases.
