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

# Move from the CLI

> Move an installation the 2501 infra CLI created onto the chart in place, keeping its objects, its data and its keys

An installation the `2501 infra` CLI created moves to the chart once, in place. The chart's objects carry the names, the selectors and the database names the CLI gives its own, so applying the chart updates the CLI's Deployments, Services, volume claims, Role and ConfigMaps, as an upgrade would: no second installation starts beside them, and the data stays where it is. Only the keys and passwords the data is bound to are copied, from the CLI's secrets into the chart's.

On Kubernetes and OpenShift, the move renders the chart with a `values.yaml` you write and applies the result with `kubectl` or `oc`, as you applied the CLI's manifests: nothing of Helm runs in the cluster. Everything the CLI set (its host name and stack name, a proxy, a storage class and size, your own database, its Elasticsearch, HTTPS to the engine) becomes a value in that `values.yaml`. On Swarm, the move deploys the published `compose.yaml` with `docker stack deploy`, the CLI's host name goes in the `.env`, and what it set beyond the standard installation in a second compose file. The engine and PostgreSQL restart once, as for an upgrade: plan a short maintenance window, during which Command Center is unavailable and no task runs.

## What carries over

| | |
| - | - |
| The objects | Updated in place, with the same names and the same selectors: Deployments `engine`, `command-center`, `postgres`, Services `ai-2501-engine`, `ai-2501-command-center`, `postgres`, ServiceAccount, Role and RoleBinding `engine-executor`, ConfigMaps `engine-config` and `command-center-config`, NetworkPolicy `mcp-hosted-isolation`. The two Services are named after the stack, `<stackName>-engine` and `<stackName>-command-center`: with the CLI's stack name in `stackName`, they are the CLI's. |
| The database | Kept in place: the chart's PostgreSQL runs on the claim `postgres-pvc`, under the role and database `2501`, as the CLI's did. Your own database stays where it is. OpenShift is the exception: the chart runs no PostgreSQL there, so the CLI's is copied into yours (see [OpenShift with the CLI's PostgreSQL](#openshift-with-the-clis-postgresql)). |
| Elasticsearch | The one the CLI ran in the cluster stays the CLI's, untouched, and the engine is pointed at it (see [The CLI's Elasticsearch](#the-clis-elasticsearch)). Your own Elasticsearch is reused the same way. |
| The keys | Copied exactly: the credential encryption key (stored credentials), the engine API key, the auth secret (user sessions, which stay signed in) and the log encryption key (secure logs). |
| The engine's HTTPS | Your choice. The chart serves the engine over plain HTTP, inside the namespace, by default. A CLI that served it over HTTPS (`engine.tls.enabled`) can keep its certificate with `engine.tls.existingSecret` ([HTTPS to the engine](https://docs.2501.ai/0.19/deployment/kubernetes.md#https-to-the-engine)), or drop it. |
| The address | Keep the same host name, so webhooks, sign-in callbacks and links keep working. |
| `engine-extra-env` | Read by the chart's engine too, unchanged (Kubernetes). |

The tenant, organizations, users, credentials, LLM providers and everything else configured in Command Center live in the database, so they carry over with it. The chart's first run finds them and creates nothing.

## Before you start

1. **Pick the build.** If the chart's release is newer than the one you run, the move is also an upgrade: the release notes of every release in between list, under Upgrade actions, what changed in what you apply. Read them as for any [upgrade](https://docs.2501.ai/0.19/deployment/upgrade.md).
2. **Keep the CLI's directory** (`/etc/2501` by default): it holds the manifests you go back with, and on Swarm the keys.
3. **Pause** anything that applies the CLI's manifests again, such as a GitOps job.
4. **Get the files**: download and [verify](https://docs.2501.ai/0.19/deployment/verify-downloads.md#verify-the-installation-files) the chart `2501-<build>.tgz` and `values.example.yaml`, or `compose.yaml` and `env.example` on Swarm, from the build's Release on [github.com/2501-ai/install](https://github.com/2501-ai/install), and make the images reachable, see [Pull the images from 2501's registry](https://docs.2501.ai/0.19/deployment/pull-from-2501.md).
5. **Carry over the CLI's settings**, as below: into your `values.yaml` on Kubernetes and OpenShift, into the `.env` and a `site.yaml` on Swarm.

| From the CLI | Kubernetes and OpenShift, in `values.yaml` | Docker Swarm |
| - | - | - |
| The stack name (`--stack-name` on Kubernetes, `ai-2501` by default; `deploy.stack_name` in `2501-infra.yml` on Swarm, `2501` by default) | `stackName`: the same name, so the CLI's Services are updated in place | The same name, given to `docker stack deploy`: it updates the CLI's services in place |
| `BASE_URL` (the `command-center-config` ConfigMap on Kubernetes, `env.command-center` on Swarm) | `commandCenter.host`: the same host name | `COMMAND_CENTER_HOST` in the `.env`: the same host name |
| `deploy.target` | `target`: `kubernetes` or `openshift` | |
| The registry the nodes pull from | `global.imageRegistry` and `global.imagePullSecrets` ([Pull the images from 2501's registry](https://docs.2501.ai/0.19/deployment/pull-from-2501.md)) | `IMAGE_REGISTRY` in the `.env` |
| `proxy.http_url`, `proxy.https_url`, `proxy.no_proxy` | `proxy.url` and `proxy.noProxy` ([HTTP proxy](https://docs.2501.ai/0.19/deployment/kubernetes.md#http-proxy)) | The HTTP proxy recipe of [Adapt to your site](https://docs.2501.ai/0.19/deployment/docker-swarm.md#adapt-to-your-site) |
| `kubernetes.storage_class` | `storageClass`: the same class, since a claim's class never changes | |
| `engine.tls.enabled` | `engine.tls.existingSecret: engine-tls`, with the CLI's certificate (step 1), or nothing: the engine then serves plain HTTP | |
| The PostgreSQL the CLI ran | `database.managed: true`, with `database.size` the claim's size (from `kubectl -n "$NS" get pvc postgres-pvc`): a claim never shrinks | Its data directory and user in `site.yaml` (see [Docker Swarm](#docker-swarm)) |
| Your own database (`DATABASE_URL` in `env.engine` on Swarm) | `database.managed: false` with its `pg-connection-v1` secret ([Your PostgreSQL](https://docs.2501.ai/0.19/deployment/kubernetes.md#your-postgresql)) | The PostgreSQL recipe of [Adapt to your site](https://docs.2501.ai/0.19/deployment/docker-swarm.md#adapt-to-your-site) |
| `elasticsearch.enabled` (Kubernetes) | `elasticsearch.mode: external`, pointed at it: [The CLI's Elasticsearch](#the-clis-elasticsearch) stays as it is | |
| `ELASTICSEARCH_URL`, your own Elasticsearch | `elasticsearch.mode: external` with its `url` and `existingSecret` ([Your Elasticsearch](https://docs.2501.ai/0.19/deployment/kubernetes.md#your-elasticsearch)) | The same variables in `site.yaml`, as for the proxy |
| `ELASTICSEARCH_ENVIRONMENT` (the `engine-config` ConfigMap on Kubernetes; Swarm leaves it to `production`) | `elasticsearch.environment`: the same prefix, so that Command Center finds the earlier logs | `production` |
| `ELASTICSEARCH_TLS_INSECURE` | Its CA in `caBundle` ([Corporate CA](https://docs.2501.ai/0.19/deployment/kubernetes.md#corporate-ca)), or `elasticsearch.tlsInsecure: true` | The same, in `site.yaml` |
| `tenant.yaml`, `organization.yaml`, `admin-user.yaml`, the LLM you use | Nothing: they are already in the database | Nothing |
| The group of `/var/run/docker.sock` | | `DOCKER_GID` in the `.env`, from `stat -c %g /var/run/docker.sock` on the managers |
| Command Center port | | `COMMAND_CENTER_PORT` in the `.env` (3000 for the CLI's) |
| `swarm.docker_config` | | Its registry logins go into the registry login secret |

The chart's first run finds your tenant as the database's only tenant and creates nothing once the installation has users and an LLM provider. Leave `identity` and the LLM settings out of `values.yaml`, `identity.tenantId` above all: one that is not your tenant's exact id makes the first run refuse the database ("pins tenant ..., the license would not match").

## Kubernetes and OpenShift

The commands use the CLI's namespace in `$NS`. Apply into the CLI's namespace: its objects are the CLI's.

| The CLI's objects | After the move |
| - | - |
| Deployments, Services, ServiceAccount, Role, RoleBinding, NetworkPolicy, ConfigMaps `engine-config` and `command-center-config` | The same objects, updated by the chart |
| Volume claims `postgres-pvc`, `elasticsearch-pvc` | The same claims, holding the same data. The chart never deletes them. |
| Secret `engine-tls` | With HTTPS to the engine, read by the chart's engine, with the certificate copied under the names it reads. Without it, no longer read |
| Deployment, Service, claim and Secret of the CLI's Elasticsearch (`elasticsearch`, `elasticsearch-pvc`, `elasticsearch-secrets`, `elasticsearch-tls`) | Left as they are, still running: the engine writes to it (see [The CLI's Elasticsearch](#the-clis-elasticsearch)) |
| Secrets `engine-secrets`, `command-center-secrets`, `postgres-secrets`, `identity-secrets`, `license-public-key`, `ecr-pull-secret`, ConfigMap `identity-config` | Left as they are and no longer read: you remove them at the end |

The chart adds its own objects beside them: the ConfigMaps `2501-settings`, `engine-extra` and `license-public-key`, a bootstrap Job and its ConfigMap, and the Ingress `command-center` (a Route on OpenShift).

### 1. Create the secrets

Copy the four keys and the database password from the CLI's secrets into the chart's, then check each copy. The database password is the one PostgreSQL already holds: a new one would not open it. The chart puts the database password into its database URL as is, so it may hold letters, digits and `- . _ ~` only; the CLI generates one that fits.

```bash theme={null}
old() { kubectl -n "$NS" get secret "$1" -o "jsonpath={.data.$2}" | base64 -d; }
kubectl -n "$NS" create secret generic credential-key-v1 --from-literal=credential-key-v1="$(old engine-secrets CREDENTIAL_DECRYPTION_KEY)"
kubectl -n "$NS" create secret generic engine-api-key-v1 --from-literal=engine-api-key-v1="$(old engine-secrets ENGINE_API_KEY)"
kubectl -n "$NS" create secret generic auth-secret-v1 --from-literal=auth-secret-v1="$(old command-center-secrets AUTH_SECRET)"
kubectl -n "$NS" create secret generic log-encryption-key-v1 --from-literal=log-encryption-key-v1="$(old engine-secrets LOG_ENCRYPTION_KEY)"
kubectl -n "$NS" create secret generic postgres-password-v1 --from-literal=postgres-password-v1="$(old postgres-secrets POSTGRES_PASSWORD)"

same() { [ -n "$(old "$1" "$2")" ] && [ "$(old "$1" "$2")" = "$(old "$3" "$3")" ] && echo "$3: same" || echo "$3: DIFFERENT"; }
same engine-secrets CREDENTIAL_DECRYPTION_KEY credential-key-v1
same engine-secrets ENGINE_API_KEY engine-api-key-v1
same command-center-secrets AUTH_SECRET auth-secret-v1
same engine-secrets LOG_ENCRYPTION_KEY log-encryption-key-v1
same postgres-secrets POSTGRES_PASSWORD postgres-password-v1
```

When the CLI served the engine over HTTPS (`engine.tls.enabled`) and you keep HTTPS, set `engine.tls.existingSecret: engine-tls` in step 3, and copy the CLI's certificate under the names the chart reads, beside the CLI's own, which going back needs. It names the engine's Service, `<stackName>-engine`, which keeps its name. To drop HTTPS, skip this: the engine then serves plain HTTP.

```bash theme={null}
old engine-tls 'api\.crt' > engine-tls.crt && old engine-tls 'api\.key' > engine-tls.key
kubectl -n "$NS" create secret generic engine-tls --dry-run=client -o yaml \
  --from-file=tls.crt=engine-tls.crt --from-file=tls.key=engine-tls.key \
  --from-file=api.crt=engine-tls.crt --from-file=api.key=engine-tls.key | kubectl -n "$NS" apply -f -
```

With the CLI's Elasticsearch, or your own, copy the engine's credentials for it too:

```bash theme={null}
kubectl -n "$NS" create secret generic es-credentials \
  --from-literal=ELASTICSEARCH_USERNAME="$(old engine-secrets ELASTICSEARCH_USERNAME)" \
  --from-literal=ELASTICSEARCH_PASSWORD="$(old engine-secrets ELASTICSEARCH_PASSWORD)"
```

Then create the other secrets, as in [Create the secrets](https://docs.2501.ai/0.19/deployment/kubernetes.md#create-the-secrets), skipping the ones you already created:

* the first administrator's password: any value, it is only read by an installation without users;
* the LLM API key: your real key, read only by an installation without an LLM provider;
* your registry's read-only login, which replaces `ecr-pull-secret` and the AWS keys;
* `cc-tls`: the certificate for the host name, which the chart's Ingress reads.

### 2. Back up the database

Back up the database with `pg_dump` while the CLI's installation runs: the backup is not part of the move, it is your way back if the chart's build migrates the database, or if anything goes wrong. With your own database, back it up as you usually do. Deleting a claim deletes the data on most storage classes; the move deletes none, but if your role may change PersistentVolumes, make the two volumes outlive their claims as well:

```bash theme={null}
kubectl -n "$NS" exec deploy/postgres -- pg_dump -U 2501 -d 2501 --format=custom > 2501-before-move.dump
for c in postgres-pvc elasticsearch-pvc; do
  kubectl patch pv "$(kubectl -n "$NS" get pvc "$c" -o jsonpath='{.spec.volumeName}')" \
    -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'
done
```

### 3. Prepare and review

Write your `values.yaml` from `values.example.yaml` with the values the table above lists, the CLI's host name in `commandCenter.host` and its stack name in `stackName`, and leave `identity` and the LLM settings out: the database already holds them. Then render and review:

```bash theme={null}
helm template ai2501 ./2501-<build>.tgz -n "$NS" -f values.yaml > manifests.yaml
kubectl -n "$NS" diff -f manifests.yaml
```

The diff shows each CLI object the chart changes, and the chart's own objects as new. With `executors.namespace` set, the Role, RoleBinding and NetworkPolicy name that namespace and every other object names `$NS`: run the commands that read `manifests.yaml` without `-n`, since kubectl refuses it for a file that spans two namespaces. No selector changes. If you added a Route or an Ingress of your own for Command Center, save it and delete it before you apply: the chart's Ingress claims the same host name.

### 4. Apply

The window starts here. Apply `manifests.yaml`, `-n "$NS"`, `--prune` and its allowlist included, as in [Apply](https://docs.2501.ai/0.19/deployment/kubernetes.md#apply). The CLI's objects carry none of the chart's labels yet, so pruning removes nothing of theirs.

Then check:

```bash theme={null}
kubectl -n "$NS" rollout status deploy/postgres
kubectl -n "$NS" rollout status deploy/engine
kubectl -n "$NS" rollout status deploy/command-center
kubectl -n "$NS" logs "job/$(kubectl -n "$NS" get jobs -l app.kubernetes.io/component=bootstrap --sort-by=.metadata.creationTimestamp -o name | tail -1 | cut -d/ -f2)"   # the newest bootstrap Job
```

The bootstrap log must report the tenant, the organizations, the first administrator and the LLM provider as already set. Its log starts with the migration step's checks (`+ probe ...` lines). While the database starts, `migrate.sh: refusing to migrate` with `P1001 Can't reach database server` can appear a few times: the bootstrap container exits, and Kubernetes restarts it after a delay that doubles each time, from 10 seconds up to 5 minutes, so its pod shows a few restarts and `BackOff` meanwhile. If it created a tenant, the chart is not on your data: go back (below). Then sign in as before, open a task from before the move to see its logs, and run a task on a host that uses a stored credential: it proves the credential key.

The CLI's PostgreSQL ran on Debian 12 and the chart's runs on Debian 13, whose C library sorts text in a newer version. PostgreSQL warns of a "collation version mismatch" on every connection until the indexes that sort text are rebuilt. Rebuild them once the chart's PostgreSQL runs, with the engine and Command Center stopped so nothing writes through the old indexes meanwhile; each database reports `changing version from 2.36 to 2.41`:

```bash theme={null}
CC_REPLICAS=$(kubectl -n "$NS" get deploy/command-center -o jsonpath='{.spec.replicas}')
kubectl -n "$NS" scale deploy/engine deploy/command-center --replicas=0
kubectl -n "$NS" wait --for=delete pod -l 'app in (engine,command-center)' --timeout=300s
kubectl -n "$NS" exec deploy/postgres -c postgres -- sh -ec 'for db in 2501 postgres template1; do
  psql -U 2501 -d "$db" -v ON_ERROR_STOP=1 -c "REINDEX DATABASE \"$db\"" -c "ALTER DATABASE \"$db\" REFRESH COLLATION VERSION"; done'
kubectl -n "$NS" scale deploy/engine --replicas=1
kubectl -n "$NS" scale deploy/command-center --replicas="$CC_REPLICAS"
```

### 5. Remove what the CLI left

Once you are satisfied, remove the CLI's secrets and ConfigMap the chart does not read. Keep `engine-extra-env`, which the chart reads, `engine-tls` if you kept HTTPS to the engine, the claims, which hold its data, and, with the CLI's Elasticsearch, `elasticsearch-secrets` and `elasticsearch-tls`, which it reads.

```bash theme={null}
kubectl -n "$NS" delete secret engine-secrets command-center-secrets postgres-secrets \
  identity-secrets license-public-key ecr-pull-secret --ignore-not-found
kubectl -n "$NS" delete configmap identity-config --ignore-not-found
```

### Going back

Until step 5, stop the chart's engine, Command Center and bootstrap (which retries its migrations), restore your backup if the chart's build is newer than the version you ran (its engine has migrated the database), then apply the CLI's manifests again: the objects return to the CLI's settings, with the data where it was. With ArgoCD, delete the application first without deleting its resources (non-cascading), or it applies the chart again:

```bash theme={null}
kubectl -n "$NS" scale deploy/engine deploy/command-center --replicas=0
kubectl -n "$NS" delete job -l app.kubernetes.io/instance=ai2501,app.kubernetes.io/component=bootstrap
kubectl -n "$NS" wait --for=delete pod -l 'app.kubernetes.io/instance=ai2501,app.kubernetes.io/component in (engine,command-center,bootstrap)' --timeout=300s
# if the chart's build is newer than the version you ran:
kubectl -n "$NS" exec deploy/postgres -- psql -U 2501 -d postgres -c 'DROP DATABASE "2501" WITH (FORCE)' -c 'CREATE DATABASE "2501" OWNER "2501" TEMPLATE template0'
kubectl -n "$NS" exec -i deploy/postgres -- pg_restore -U 2501 -d 2501 --exit-on-error < 2501-before-move.dump
kubectl apply -f /etc/2501/kubernetes/ -R   # the CLI's directory
kubectl -n "$NS" rollout status deploy/engine
```

The CLI's PostgreSQL warns of the collation version the other way: rebuild the indexes as in step 4.

The CLI's Elasticsearch never stopped: it goes back as it is. With the backup restored, what changed on the chart's installation since the window is lost; without it, those changes stay.

Applying the CLI's manifests leaves the objects only the chart has: its Ingress, its ConfigMaps (`2501-settings`, `engine-extra`, `license-public-key`, `bootstrap-<hash>`) and its secrets. Delete them, or the Ingress keeps the host name.

```bash theme={null}
kubectl -n "$NS" delete ingress command-center
kubectl -n "$NS" delete configmap 2501-settings engine-extra license-public-key --ignore-not-found
kubectl -n "$NS" get configmap -o name | grep '^configmap/bootstrap-' | xargs -r kubectl -n "$NS" delete
kubectl -n "$NS" delete secret credential-key-v1 engine-api-key-v1 auth-secret-v1 log-encryption-key-v1 \
  admin-password-v1 llm-api-key-v1 postgres-password-v1 es-credentials --ignore-not-found
```

To move again later, start over from step 1: going back removes those secrets, and the CLI's manifests take `tls.crt` and `tls.key` out of `engine-tls`.

### The CLI's Elasticsearch

With `elasticsearch.mode: external`, the chart runs no Elasticsearch of its own, so the one the CLI ran (`elasticsearch.enabled`) stays the CLI's: its Deployment, Service, claim `elasticsearch-pvc` and secrets carry only the CLI's labels (`app`, `stack`), which the prune selector never matches, so applying the chart leaves it running as it is, with its logs. Point the engine and Command Center at it in your `values.yaml`, with the CLI's values from `engine-config` (`kubectl -n "$NS" get configmap engine-config -o jsonpath='{.data}'`): its URL, `https://elasticsearch:9200`, and its index prefix, `ELASTICSEARCH_ENVIRONMENT`. Its certificate is the CLI's self-signed one, so accept it as the CLI did, or trust the CA in `elasticsearch-tls` with `caBundle` ([Corporate CA](https://docs.2501.ai/0.19/deployment/kubernetes.md#corporate-ca)):

```yaml theme={null}
elasticsearch:
  mode: external
  url: https://elasticsearch:9200
  existingSecret: es-credentials   # from step 1
  environment: production          # the CLI's ELASTICSEARCH_ENVIRONMENT
  tlsInsecure: true                # the CLI's self-signed certificate
```

Check after step 4: the document counts in its indices grow (`kubectl -n "$NS" exec deploy/elasticsearch -c elasticsearch -- curl -sk -u "elastic:<ELASTIC_PASSWORD>" 'https://localhost:9200/_cat/indices?h=index,docs.count'`). It keeps running on the CLI's image: update it later as your own object, or move its logs to an Elasticsearch you run.

### OpenShift with the CLI's PostgreSQL

On OpenShift the chart runs no PostgreSQL and no Elasticsearch, so the CLI's cannot be taken over. Create your own database, empty, with a user that owns it, and its `pg-connection-v1` secret, as in [Create the secrets](https://docs.2501.ai/0.19/deployment/kubernetes.md#create-the-secrets), and set `database.managed: false`. Before step 4, stop the CLI's engine and Command Center and copy its database into yours from a machine that reaches it, with `pg_restore` 16 or newer; the chart then starts them again on yours:

```bash theme={null}
kubectl -n "$NS" scale deploy/engine deploy/command-center --replicas=0
kubectl -n "$NS" wait --for=delete pod -l 'app in (engine,command-center)' --timeout=300s
kubectl -n "$NS" exec deploy/postgres -- pg_dump -U 2501 -d 2501 --format=custom > 2501.dump
DIRECT_URL=$(kubectl -n "$NS" get secret pg-connection-v1 -o jsonpath='{.data.DIRECT_URL}' | base64 -d)
pg_restore -d "$DIRECT_URL" --no-owner --no-privileges --exit-on-error 2501.dump
```

The restore must end without an error. If one stops it, recreate your database empty before you try again. The chart leaves the CLI's PostgreSQL and Elasticsearch running: once you are satisfied, delete their Deployments and Services (`postgres`, `elasticsearch`), then their claims, which deletes their data.

## Docker Swarm

The commands use the CLI's directory in `$D` (`/etc/2501`, or `~/.2501/default` for an install under your home directory) and its stack name in `$STACK` (`deploy.stack_name` in `2501-infra.yml`, `2501` by default). Run them on the manager where you ran `2501 infra deploy`, next to `compose.yaml`, your `.env` (from `env.example`) and a `site.yaml` of yours (see [Adapt to your site](https://docs.2501.ai/0.19/deployment/docker-swarm.md#adapt-to-your-site)).

The CLI ran the services `engine` and `command-center` in its stack, read their settings from `env.engine` and `env.command-center`, and, with `postgres.enabled`, a PostgreSQL container `$STACK_postgres` outside the stack, with its data in a directory of this node (`postgres.data_path`, `/var/lib/2501/postgres` by default). `compose.yaml` has the same `engine` and `command-center` services: deployed under the same stack name, it updates them in place, and adds the database, which takes over the container's directory, and the bootstrap. The CLI ran no Elasticsearch on Swarm.

When the CLI ran PostgreSQL, its container holds the user, the database and the password the data was created with:

```bash theme={null}
pg() { docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "${STACK}_postgres" | sed -n "s/^$1=//p"; }
pg POSTGRES_USER; pg POSTGRES_DB                          # postgres and 2501 by default
docker inspect -f '{{range .Mounts}}{{.Source}}{{end}}' "${STACK}_postgres"   # its data directory
```

`compose.yaml` runs its database as user and database `2501`. Give `site.yaml` the CLI's instead, and its data directory as the volume, here for the defaults `postgres` and `2501` (the CLI's container and ours keep the data in the same place, `PGDATA=/var/lib/postgresql/data/pgdata`):

```yaml theme={null}
version: "3.8"
x-cli-database: &cli-database
  DATABASE_URL: "postgresql://postgres:$$(POSTGRES_PASSWORD)@postgres:5432/2501?sslmode=disable"
  DIRECT_URL: "postgresql://postgres:$$(POSTGRES_PASSWORD)@postgres:5432/2501?sslmode=disable"
services:
  postgres:
    environment: { POSTGRES_USER: postgres, POSTGRES_DB: "2501" }
  engine: { environment: *cli-database }
  command-center: { environment: *cli-database }
  bootstrap: { environment: *cli-database }
volumes:
  postgres-data: { name: 2501-postgres, external: true }
```

`$$(POSTGRES_PASSWORD)` stays as it is: the services put the password from the `postgres-password-v1` secret there when they start. Check the merge with `docker stack config -c compose.yaml -c site.yaml`: the three services show the CLI's user in their `DATABASE_URL`, and `postgres-data` is `2501-postgres`.

### 1. Create the secrets and the volume

Copy the four keys from the CLI's files, and the database password from its container. Run `val <name> <file>` and `pg POSTGRES_PASSWORD` alone first: each must print a value, and a Docker secret cannot be corrected in place. The database password goes into the database URL as is, so it may hold letters, digits and `- . _ ~` only (the CLI's default, `change-me`, fits). If yours holds anything else, change it first, while the CLI still runs: `ALTER ROLE <user> PASSWORD '<new password>';` in its database. The container keeps the password it was created with, so create the secret from the new one, `printf %s '<new password>' | secret postgres-password-v1`, instead of the `pg POSTGRES_PASSWORD` line below.

```bash theme={null}
val() { sed -n "s/^$1=//p" "$D/$2" | tail -n 1; }
secret() { docker secret inspect "$1" >/dev/null 2>&1 || docker secret create "$1" -; }
printf %s "$(val CREDENTIAL_DECRYPTION_KEY env.engine)" | secret credential-key-v1
printf %s "$(val ENGINE_API_KEY env.engine)" | secret engine-api-key-v1
printf %s "$(val AUTH_SECRET env.command-center)" | secret auth-secret-v1
printf %s "$(val LOG_ENCRYPTION_KEY env.engine)" | secret log-encryption-key-v1
printf %s "$(pg POSTGRES_PASSWORD)" | secret postgres-password-v1   # when the CLI ran PostgreSQL
```

With your own database, copy its URLs (if `env.engine` has no `DIRECT_URL`, use `DATABASE_URL` for both), and with your own Elasticsearch, its credentials. The engine connects with the URL exactly as the secret holds it. The CLI's URLs often say `sslmode=disable`, so the commands below change it to `verify-full`, which the production profile requires. A URL with no `sslmode` keeps none: add `sslmode=verify-full` to its parameters yourself.

```bash theme={null}
printf %s "$(val DATABASE_URL env.engine | sed 's/sslmode=[a-z-]*/sslmode=verify-full/')" | secret pg-connection-v1.DATABASE_URL
printf %s "$(val DIRECT_URL env.engine | sed 's/sslmode=[a-z-]*/sslmode=verify-full/')" | secret pg-connection-v1.DIRECT_URL
printf %s "$(val ELASTICSEARCH_USERNAME env.engine)" | secret es-credentials.ELASTICSEARCH_USERNAME
printf %s "$(val ELASTICSEARCH_PASSWORD env.engine)" | secret es-credentials.ELASTICSEARCH_PASSWORD
```

Then run the commands at the top of `compose.yaml`, once you have replaced every `<...>` value in them: a Docker secret never changes in place, and the commands skip a secret that exists, so a placeholder would stay. They create the first administrator's password (any value, only read by an installation without users), your LLM API key and the registry login. Skip the `docker stop` line for now: step 2 runs it.

When the CLI ran PostgreSQL, give the stack its data directory as the Docker volume `2501-postgres` your `site.yaml` names, and label this node, where the directory lives, for the database. Creating the volume does not touch the data:

```bash theme={null}
docker volume create --driver local --opt type=none --opt o=bind \
  --opt device="$(docker inspect -f '{{range .Mounts}}{{.Source}}{{end}}' "${STACK}_postgres")" 2501-postgres
docker node update --label-add 2501-data=true "$(docker info -f '{{.Swarm.NodeID}}')"
```

### 2. Back up and stop the CLI's PostgreSQL

The window starts here. Back up the database with `pg_dump` (with your own database, back it up as you usually do): it is your way back if the chart's build migrates the database, or if anything goes wrong. Then stop the CLI's PostgreSQL container, without removing it: two PostgreSQL servers on one directory damage it. The CLI's engine and Command Center keep running, without a database, until the deploy replaces them.

```bash theme={null}
docker exec "${STACK}_postgres" pg_dump -U "$(pg POSTGRES_USER)" -d "$(pg POSTGRES_DB)" --format=custom > 2501-before-move.dump   # when the CLI ran PostgreSQL
docker stop "${STACK}_postgres"          # when the CLI ran PostgreSQL
```

### 3. Deploy the chart's stack

Deploy under the CLI's stack name, with your `.env` loaded and `site.yaml` merged over `compose.yaml`. The deploy stops with the name of any setting the `.env` still lacks.

```bash theme={null}
set -a; . ./.env; set +a
[ -z "$IMAGE_REGISTRY" ] || docker login "${IMAGE_REGISTRY%%/*}"
docker stack deploy --with-registry-auth --prune -c compose.yaml -c site.yaml "$STACK"
docker service logs -f "${STACK}_bootstrap"
```

`docker stack deploy` updates `$STACK_engine` and `$STACK_command-center` in place and starts the chart's PostgreSQL on the CLI's directory, under its user and database. The bootstrap log must report the tenant, the organizations, the first administrator and the LLM provider as already set. Its log starts with the migration step's checks (`+ probe ...` lines). While the database starts, `migrate.sh: refusing to migrate` with `P1001 Can't reach database server` can appear a few times: the bootstrap container exits, and Swarm starts a new one 10 seconds later, up to 10 times. If it created a tenant, the stack is not on your data: go back (below). Then point your load balancer at `COMMAND_CENTER_PORT` if it changed, sign in as before, and run a task on a host that uses a stored credential.

The CLI created its database with the `C` locale, which sorts text the same on every system. If `docker service logs "${STACK}_postgres"` still warns of a "collation version mismatch", rebuild the indexes as on Kubernetes, with `docker exec` in the postgres container and the CLI's user.

### 4. Remove what the CLI left

Once you are satisfied:

```bash theme={null}
docker rm "${STACK}_postgres"          # when the CLI ran PostgreSQL
docker network rm "${STACK}-network"
```

Keep the data directory and the `2501-postgres` volume: they hold the chart's database now. The CLI's directory holds the keys in plain text: keep it somewhere protected, or delete it, then uninstall the CLI.

### Going back

Until step 4, stop the chart's engine, Command Center and bootstrap (which retries its migrations), and wait until their containers are gone, so that nothing writes to the database while it is restored: with your own database, they still reach it. Run `gone` on every node. It fails after five minutes; do not go on until it succeeds:

```bash theme={null}
docker service scale "${STACK}_engine=0" "${STACK}_command-center=0"
docker service rm "${STACK}_bootstrap"
gone() {
  for i in $(seq 60); do
    c=$(for s in engine command-center bootstrap; do docker ps -q -f "label=com.docker.swarm.service.name=${STACK}_$s" || exit 1; done) || return 1
    [ -z "$c" ] && return 0
    sleep 5
  done
  echo "engine, Command Center or bootstrap still running after 5 minutes" >&2; return 1
}
gone
```

When the CLI ran PostgreSQL, remove the chart's database service and wait until its container is gone, so that the directory has one server again, then start the CLI's PostgreSQL container:

```bash theme={null}
docker service rm "${STACK}_postgres"
until [ -z "$(docker ps -q -f "label=com.docker.swarm.service.name=${STACK}_postgres")" ]; do sleep 2; done
docker start "${STACK}_postgres"
until docker exec "${STACK}_postgres" pg_isready -U "$(pg POSTGRES_USER)"; do sleep 2; done
```

If the chart's build is newer than the version you ran, its engine has migrated the database: restore your backup now, before the CLI's engine runs on it. With your own database, restore your own backup of step 2 into it; into the CLI's PostgreSQL:

```bash theme={null}
U=$(pg POSTGRES_USER) DB=$(pg POSTGRES_DB)
docker exec "${STACK}_postgres" psql -U "$U" -d postgres -c "DROP DATABASE \"$DB\" WITH (FORCE)" -c "CREATE DATABASE \"$DB\" OWNER \"$U\" TEMPLATE template0"
docker exec -i "${STACK}_postgres" pg_restore -U "$U" -d "$DB" --exit-on-error < 2501-before-move.dump
```

Remove the chart's image services, which the CLI's deploy would count as not ready, then deploy the CLI's installation again from its directory, at the version you ran: it updates the engine and Command Center back.

```bash theme={null}
docker service rm "${STACK}_executor-mcp-image" "${STACK}_kerberos-worker-image" 2>/dev/null   # kerberos-worker-image only when the build pins it
2501 infra deploy --dir "$D" --tag <the version you ran>
```

With the backup restored, what changed on the chart's installation since the window is lost; without it, those changes stay. The chart's secrets, its network `${STACK}-net`, the volume `2501-postgres` and the node label stay too: they are harmless, and a later move reuses them.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.