---
title: Migrating off Zeabur — reference for coding agents
audience: coding agents (Claude Code, Codex, Cursor, …) helping a Zeabur user move their services
source: https://zeabur.com/blogs/migrate-from-zeabur.md
---

# Migrating off Zeabur

Zeabur is shutting down. Your user has services on Zeabur and asked you to help move them. This document describes how Zeabur runs those services and what Zeabur did on the user's behalf, so that you can inspect their actual setup, recommend a destination that fits them, and carry out the move.

This document does not prescribe a destination or a procedure. Pick them with the user. Talk to the user in their language.

This document is updated as the shutdown progresses. Fetch it again from the source URL at the start of each session instead of relying on a cached copy.

## Ground rules

- **Never print secrets.** Passwords, tokens, keys and connection strings stay out of replies, logs, commits and files under version control. Show variable names with masked values. Compare values by hash.
- **Treat these outputs as secret:** `zeabur project export`, `zeabur variable list`, `kubectl get deploy -o yaml`, `kubectl get secret`. Store them only as `chmod 600` files on the user's machine or the target host.
- **Do not change or delete anything on Zeabur** (services, variables, domains, volumes) or on the old host. The exceptions are suspending application services during cutover and the [Cleanup](#cleanup) after a stable cutover, each only after the user agrees.
- **Keep a rollback path** until the user confirms the new environment is stable. Remind them of their expiry date: once it passes, there is no rollback.
- **Ask before:** choosing the destination, anything that costs money, DNS changes (the user makes DNS changes themselves), and cutover.
- **Never ask the user to paste a Zeabur API token, server password or private key into the chat.** Use the Zeabur CLI already signed in on their machine (`npx zeabur@latest`; add `-i=false` for non-interactive use and `--json` for structured output). For server access, follow [Server access](#server-access).
- **Rehearse on the destination, not on the user's computer.** Validate manifests and restores on the destination host, or on a throwaway host the user provides. Start k3s, k3d, kind, minikube, Docker or the user's services on their computer only if they explicitly agree. Exporting data and keeping `chmod 600` files on their machine is fine.
- **Agree on the scope first.** Ask which servers or projects to move before inventorying. Do not inventory every project in every workspace.
- **The user provides the destination.** Once a destination is chosen, ask the user to create the host or platform account and give you access as described in [Server access](#server-access). Do not deploy anything until it exists.
- When something in this document does not match what you observe, trust the observation and tell the user.

## Shutdown timeline (2026)

| Date | What happens |
| --- | --- |
| Now | No new dedicated servers or domain purchases. Domains bought on Zeabur can be transferred out from the dashboard. |
| From Dec 1 | Dedicated servers stop renewing and shut down on their own expiry dates. Shared-cluster services are suspended; users can resume them manually to back up data. |
| Dec 31 | All dedicated servers and shared clusters stop. |

Data deletion dates have not been announced. Plan around the user's expiry date, not Dec 31.

## Before you start

1. **CLI and account.** Run `npx zeabur@latest auth status`. If the CLI is missing, use `npx zeabur@latest` (no install needed). If it is not logged in or returns `401 Unauthorized`, ask the user to run `npx zeabur@latest auth login` themselves (in Claude Code: `! npx zeabur@latest auth login`). Confirm the logged-in account is the one that owns the services; it may differ from the user's work email.
2. **Workspaces.** Run `npx zeabur@latest workspace list`. Services can live in the personal workspace or in any team. Add the global `--workspace <workspaceID>` flag to query a team.
3. **Scope.** Ask which servers or projects to move. List servers with `npx zeabur@latest server list` and projects with `npx zeabur@latest --json project list`. A project runs on a dedicated server when its `Region.ID` is `server-<serverID>`; that is the only way to map servers to projects.
4. **Deployment type.** `npx zeabur@latest --json server get <serverID>`: `IsManaged: true` is a server rented through Zeabur; `false` is an unmanaged server that belongs to the user.
5. **Expiry date.** The CLI does not return it. Ask the user to read it from the server's **Settings** tab in the Zeabur dashboard, or from their billing for shared clusters.

CLI notes:

- Pass the server ID positionally: `server exec <serverID> -- <command>`. `--id` does not work with `server exec`.
- `service list --project-id <projectID>`, `deployment list --service-id <serviceID>`, `service exec --id <serviceID> -- <command>`.
- There is no environment command and `project get` does not list environments. Take the environment ID from the dashboard URL (`envID=` query parameter) or from the `environment-<environmentID>` namespace on the host.
- Wrap every remote call in a timeout (for example `timeout 60 npx zeabur@latest …`). `service exec` sometimes returns `504 Gateway Timeout`; retry it.

## Identify the deployment type

| Type | How to tell | What happens to the machine | What you can access |
| --- | --- | --- | --- |
| Rented dedicated server | `IsManaged: true` in `server get`. Bought through Zeabur (Hetzner, Linode, DigitalOcean, GCP, Tencent Cloud, Alibaba Cloud, Volcengine, Lightsail, …) | Owned by Zeabur's cloud account. Powered off and deleted after expiry; disk data is not kept. | Root SSH. See [Server access](#server-access). |
| Unmanaged server | `IsManaged: false`. A machine in the user's own cloud account: one they registered with Zeabur, or a rented server that has been handed over to their account | Stays with the user. Zeabur's components on it stop working. | SSH with the user's own credentials. See [Server access](#server-access). |
| Shared cluster | No server of their own; services run in a Zeabur region | Services suspended, then stopped | No SSH and no kubeconfig. Zeabur CLI and dashboard only. |

On dedicated servers you can read the real state directly from k3s. That is the most reliable inventory source.

## Server access

Rented dedicated servers (old host):

1. Find the server ID with `npx zeabur@latest server list`.
2. Try `npx zeabur@latest server exec <serverID> -- <command>`. It uses the credential Zeabur holds for the server without printing it. Run every host-side inventory command this way. Do not run `server ssh-info`: it prints the password.
3. If it reports that no password is available or that Zeabur does not store this server's SSH credentials, Zeabur cannot reach the host for you. If you only need data from inside containers, use [Without host access](#without-host-access) instead. Otherwise, continue here. Ask the user to click **Reset SSH password** on the server's page in the Zeabur dashboard. The new password is emailed to the server owner, and Zeabur does not keep it, so `server exec` keeps failing. Continue with step 4.
4. Set up key access without the password entering the chat. Generate a dedicated key on the user's machine, then ask the user to install it from **their own terminal**, typing the emailed password there:

   ```bash
   ssh-keygen -t ed25519 -N '' -f ~/.ssh/zeabur-migration -C zeabur-migration   # you run this
   ssh-copy-id -i ~/.ssh/zeabur-migration.pub -p <sshPort> <user>@<serverIP>     # the user runs this
   ```

   Then connect with `ssh -i ~/.ssh/zeabur-migration -p <sshPort> <user>@<serverIP>`. Get the IP, SSH port and username from `npx zeabur@latest --json server get --id <serverID>`; the username is `root` unless the output says otherwise, in which case prefix host commands with `sudo`.

Unmanaged servers: Zeabur holds no credential for them, so `server exec` does not work. Use the SSH access the user already has. If they have none (for example, a server handed over from Zeabur), ask them to reset the root password or add the public key from step 4 in their cloud provider's console, then continue with the key flow in step 4.

Destination host, if the user chose one: the user creates it with the provider and size you agreed on, then installs the same key with `ssh-copy-id` from their own terminal, or adds the public key when creating the host. Remove the key from both hosts at handover unless the user wants to keep it.

When a connection is refused, check whether the key is accepted before guessing at the cause:

```bash
ssh -v -o BatchMode=yes -o PreferredAuthentications=publickey -i ~/.ssh/zeabur-migration -p <sshPort> <user>@<serverIP> true
```

- If the output shows `Offering public key` but no `Server accepts key`, the key is not on the host. `ssh-copy-id` reporting `1 key(s) remain to be installed` means the same.
- Do not ask the user to retry the password. You cannot see what they type. Ask them to add the public key to `~/.ssh/authorized_keys` through their provider's web or serial console, or the provider's SSH key settings, then rerun the command above to confirm before continuing.
- Diagnose only after you are in: `sshd -T | grep -E 'permitrootlogin|passwordauthentication'` shows the effective settings, and `journalctl -u ssh` shows attempts. `Failed password` means a wrong password; look at sshd settings only for `Failed publickey` or no password attempts at all.

## Platform model

Every dedicated server is a single-node k3s cluster. k3s was installed with `curl -sfL https://get.k3s.io | sh -` at registration time (version not pinned). Run `k3s kubectl …` on the host, or use `/etc/rancher/k3s/k3s.yaml`. Shared clusters use the same object model on Zeabur-managed multi-tenant Kubernetes.

| Zeabur concept | Kubernetes object | Notes |
| --- | --- | --- |
| Project + environment | Namespace `environment-<environmentID>` | Labels: `zeabur_project_id`, `zeabur_project_name`, `zeabur_environment_id`, `zeabur_environment_name`. List them with `kubectl get ns -L zeabur_project_name`. |
| Service | Deployment `service-<serviceID>` | Pod labels: `zeabur_service_id`, `zeabur_project_id`, `zeabur_environment_id`, `zeabur_type=user-service`. Some older services are a StatefulSet or a Deployment named `deployment-<id>`. |
| Service address | Service `service-<serviceID>` | Labelled `app.kubernetes.io/name=<dnsName>`, which is how `<dnsName>.zeabur.internal` resolves. |
| Environment variables | Inline `env` in the pod spec | Fully expanded plain values, not `secretKeyRef`. `kubectl get deploy service-<id> -o yaml` shows the effective value of every variable. |
| Config file | One ConfigMap per file, mounted with `subPath` | |
| Volume | PVC `<volumeID>-service-<serviceID>`, ReadWriteOnce, cluster default StorageClass | On dedicated servers this is k3s local-path; data lives under `/var/lib/rancher/k3s/storage/`. |
| Domain | Ingress `domain-<domainID>` in the environment namespace | Served by Zeabur's own ingress controller (see Networking). |
| Public TCP/UDP port | NodePort Service | Reachable at the server IP plus the NodePort. |
| Health check | startupProbe and readinessProbe | Only when the user configured one. There is no livenessProbe. |
| Daily backup | CronJob `backup-<serviceID>` | Uploads to Zeabur's object storage. |
| Scheduled restart | CronJob that restarts the service | |

Other pod settings: containers run as UID 0; the update strategy is Recreate when the service has volumes (forced to 1 replica), otherwise RollingUpdate; resource limits are unset on dedicated servers unless the user set them; the memory request is the 6-hour average usage.

Crash handling: after a service hits its failure threshold in CrashLoopBackOff (default 3), Zeabur suspends it. Suspending deletes the Deployment but keeps PVCs. A PVC without a matching Deployment is often a suspended service whose data still exists.

## Container images

This is the most common way a migrated service breaks.

- Services built from Git are pushed to Zeabur's own registry: `registry-oci.zeabur.cloud`, `registry-oci-iad1.zeabur.cloud`, `registry-oci.zeabur.cn` (older: `registry.zeabur.com`, `registry.zeabur.cn`). The path is `<host>/e-<environmentID>/s-<serviceID>:d-<deploymentID>`. **These registries stop serving when Zeabur shuts down.**
- Every pod uses `imagePullPolicy: Always`. Once the registry is gone, any restart or reschedule fails with `ErrImagePull`, even if the image is still in the node's cache.
- A DaemonSet runs `crictl rmi --prune` every 24 hours, so only images used by running containers survive on the node. Previous versions are usually gone; only the currently running image can be exported.
- To export a running image on a dedicated server: `k3s ctr -n k8s.io images ls`, then `k3s ctr -n k8s.io images export <file>.tar <ref>`. Use an exported image as a stopgap. The long-term answer is a reproducible build.
- Template and prebuilt services use public images (mostly Docker Hub). On mainland-China servers, `zeabur/*` images are rewritten to `swr.cn-east-3.myhuaweicloud.com/zeabur/docker.io/…`.
- Resolve floating tags such as `latest` to the version actually running (the pod's `imageID`, or ask the service for its version), then pin it in the new environment.

## Build

Zeabur builds Git services with zbpack (open source: https://github.com/zeabur/zbpack).

- Detection order: a Dockerfile wins unless ignored. Otherwise Zeabur checks, in order, for Dart, PHP, Ruby, Bun, Python, Node.js, Go, Java, Deno, Rust, .NET, Elixir, Gleam, Swift and static sites.
- Configuration precedence: `ZBPACK_*` environment variables, then `zbpack.<submodule>.json`, then `zbpack.json`. Each key maps to an upper snake-case variable, for example `build_command` → `ZBPACK_BUILD_COMMAND`. Keys include `install_command`, `build_command`, `start_command`, `pre_start_command`, `output_dir`, `app_dir`, `plan_type`, `ignore_dockerfile`, `node.framework`, `python.version`, `python.entry`, `php.version`, `go.entry` and `rust.entry`.
- A service can also have a custom build or start command: `zeabur --json service get --id <serviceID>`.
- Build-time variables: `ZEABUR_GIT_COMMIT_SHA`, `ZEABUR_GIT_COMMIT_MESSAGE`.
- Git services listen on port 8080 unless zbpack detected another port.
- When writing a Dockerfile, reproduce zbpack's behaviour for that language (runtime version, install, build and start commands). Read zbpack's source when unsure.
- Static sites are served by a Caddy build with a Zeabur-only `zeaburextension` directive in the generated Caddyfile. The official `caddy` image fails to start with it; remove the directive.

## Variables

- Variables marked **exposed** are shared with every other service in the same environment. In `zeabur --json variable list`, `variables` holds the service's own variables and `readonlyVariables` holds the ones it receives from other services. Applications often connect to their database through the received ones (for example `POSTGRES_CONNECTION_STRING`, `REDIS_URI`), which do not appear in the application's own list.
- Values can reference other variables with `${VAR}`. Expansion happens at deploy time. `project export` and `variable list` return **unexpanded** values such as `${PASSWORD}`. The expanded values are in the pod spec.
- Database-style services get a random 32-character `PASSWORD` at creation.
- Search the application's code for the variables it actually reads, and make sure each one has a value in the new environment.

Zeabur-provided variables to translate:

| Variable | Value on Zeabur |
| --- | --- |
| `CONTAINER_HOSTNAME` | `service-<serviceID>` |
| `<PORT>_PORT` (for example `WEB_PORT`, `DATABASE_PORT`) | Port number. Template services also get `PORT` set to their first port. |
| `ZEABUR_<PORT>_URL`, `ZEABUR_<PORT>_DOMAIN` (for example `ZEABUR_WEB_URL`) | The public URL and domain of that port |
| `<SERVICE_NAME>_HOST` | Address of another service in the project |
| `PORT_FORWARDED_HOSTNAME`, `<PORT>_PORT_FORWARDED_PORT` | Server IP and NodePort, when public networking is on |
| `ZEABUR`, `ZEABUR_SERVICE_ID`, `ZEABUR_PROJECT_ID`, `ZEABUR_ENVIRONMENT_ID`, `ZEABUR_USER_ID`, `ZEABUR_REGION` | Platform identifiers |
| `NUXT_HOST`, `NUXT_PORT`, `HOST=0.0.0.0`, `PYTHONUNBUFFERED=1` | Framework defaults for Nuxt, Astro SSR and Python |
| `POSTGRES_*`, `MYSQL_*`, `MONGODB_URI`, `REDIS_URI` | Legacy marketplace database variables |

## Networking and domains

- **Internal DNS.** `<dnsName>.zeabur.internal` is served by Zeabur's own CoreDNS build (`zeabur-dns` in `default`). Pods use `DNSPolicy: None` with search domain `environment-<environmentID>.zeabur.internal`. Replace every `.zeabur.internal` reference in variables and config files with the new environment's service names.
- **Ingress and TLS on dedicated servers.** Ports 80 and 443 are handled by `zeabur/ingress-controller`, a hostNetwork DaemonSet in `default`. It is not ingress-nginx or Traefik. It obtains Let's Encrypt certificates via HTTP-01 itself and stores them as Secrets named after the host in `default`. Replace it with a standard reverse proxy or ingress controller.
- **Generated `*.zeabur.app` domains.** On dedicated servers these are Zeabur-managed A records pointing at the server IP. **They stop resolving when Zeabur shuts down.** Find every hard-coded `.zeabur.app` URL in code, OAuth callbacks, webhooks and payment provider settings.
- **Custom domains.** On dedicated servers they are A records pointing at the server IP. On shared clusters they are CNAMEs to Zeabur.
- **Domains bought on Zeabur** must be transferred to another registrar from the dashboard.

## Zeabur components on a dedicated server

These stop working or become meaningless after shutdown. Do not build on them. Remove them only if the user keeps the machine and agrees.

| Component | Where | Purpose |
| --- | --- | --- |
| `ingress-controller` | `default`, DaemonSet, hostNetwork | HTTP(S) routing and certificates |
| `zeabur-dns` | `default` | `.zeabur.internal` resolution |
| `zeabur-api-token` | `default`, Secret | Connection back to Zeabur |
| fluent-bit, vector, `log-api`, nats | `default` | Log collection and shipping |
| cadvisor, node-exporter, victoria-metrics | `default` | Metrics |
| `kube-watch` | `default` | Reports cluster events to Zeabur |
| `crictl-image-prune` | `kube-system`, DaemonSet | Daily image pruning |
| `server-controller`, cert-manager | `zeabur-system` (newer servers) | Operator-managed components |
| `*.<serverID>.servers.onzeabur.com` | DNS | Zeabur-managed records for the server |

## What Zeabur did, and what must replace it

Use this as the planning checklist. For each row, state what covers it in the new environment, or why the user does not need it.

| Capability | How Zeabur provided it | After shutdown |
| --- | --- | --- |
| Build from source | zbpack, triggered by GitHub App webhooks | Gone |
| Image storage | Zeabur's registries | Gone; pulls fail |
| Deploy on push, watch paths | GitHub App push webhooks | Gone |
| Deployment history, rollback, redeploy | Zeabur API on top of stored images | Gone |
| PR preview environments | Preview environments per pull request (deprecated) | Gone |
| Generated domain | `*.zeabur.app` | Stops resolving |
| TLS | `ingress-controller` with Let's Encrypt HTTP-01 | Must be replaced |
| Service discovery | `zeabur-dns` and `.zeabur.internal` | Must be replaced |
| Variable sharing and expansion | Exposed variables and `${VAR}` | Carry over the expanded values |
| Public database ports | NodePort plus `PORT_FORWARDED_*` | Decide whether it is still needed; add access control |
| Database backups | Daily CronJob to `zeabur-backups-*` object storage; download links valid for 15 minutes | Backups are lost; export them now and set up your own |
| Logs | VictoriaLogs (dedicated, default retention 7 days) or ClickHouse (shared) | Not migrated |
| Metrics | VictoriaMetrics or Prometheus-compatible queries | Not migrated |
| Alerts | Emails for build failures and crashes; server resource alerts | Gone |
| Restart and crash handling | Kubernetes restart, auto-suspend after the failure threshold, scheduled restart CronJob | Must be replaced |
| Resource limits | Per-service CPU and memory limits | Configure in the new environment |
| Team access | Administrator / Editor / Viewer roles; project collaborators | Recreate on the new platform |
| Host maintenance | k3s install, image pruning, server alerts | The user's responsibility if they keep a host |

## Inventory

Zeabur side (all users):

```bash
npx zeabur@latest auth status
npx zeabur@latest project get --id <projectID>              # must not return FORBIDDEN
npx zeabur@latest project export --id <projectID>           # services, ports, volumes, configs (variables unexpanded)
npx zeabur@latest service list --project-id <projectID>
npx zeabur@latest --json variable list --id <serviceID>     # variables + readonlyVariables
npx zeabur@latest domain list --id <serviceID>
npx zeabur@latest --json service get --id <serviceID>       # source repo, branch, root dir, custom commands
npx zeabur@latest deployment log --service-id <serviceID> -t runtime
```

The project ID is the 24 characters after `project-` in the dashboard URL.

Host side (dedicated servers), run through `server exec` or SSH as described in [Server access](#server-access):

```bash
k3s kubectl get ns -L zeabur_project_name
k3s kubectl -n environment-<envID> get deploy,sts,svc,pvc,cm,ingress
k3s kubectl -n environment-<envID> get deploy service-<serviceID> -o yaml   # secret: effective env
du -sh /var/lib/rancher/k3s/storage/*
k3s ctr -n k8s.io images ls
```

### Without host access

Shared clusters, and rented servers Zeabur cannot reach, have no host access. Work inside containers with `npx zeabur@latest service exec --id <serviceID> -- <command>`. Backups can also be downloaded from the dashboard.

There is no file download command. Stream an archive through base64 and drop the CLI's log lines:

```bash
timeout 300 npx zeabur@latest service exec --id <serviceID> -i=false -- sh -c 'tar czf - -C / <path> | base64' \
  | grep -v -E '^(INFO|ERROR)' | base64 -d > export.tgz
tar tzf export.tgz > /dev/null   # verify the archive
```

Inside containers, connect to databases on `127.0.0.1`, not `localhost`: `psql -h localhost` has been seen to hang while `pg_dumpall -h 127.0.0.1` works.

Services deployed by upload, or whose source the user no longer has, have no repository to rebuild from. Export the built files from the running container instead (for Zeabur static sites, `/usr/share/caddy` and the Caddyfile).

Report one row per service: name, role (app, database, cache, other), source (image or repository), ports, domains, volumes and data size, variables received from other services, whether the image can be rebuilt or exported, and migration risks.

## Choosing a destination

Before recommending, ask what you cannot infer: how comfortable the user is operating servers, budget, which Zeabur conveniences they want to keep (deploy on push, managed TLS, backups), and their expiry date.

Common directions. Compare at least two with monthly cost, ongoing maintenance, migration effort and expected downtime.

- **Managed PaaS** (Railway, Render, Fly.io, …): closest to the Zeabur experience, no host to maintain. A good default for users who are not comfortable running servers.
- **Self-hosted PaaS** (Coolify, Dokploy, …): deploy-on-push experience on a host the user controls.
- **k3s on a new host:** mirrors Zeabur's model. The manifests above translate directly once Zeabur-specific pieces are replaced.
- **Docker Compose plus a reverse proxy:** simplest to understand for a few services on one host.
- **Take over the existing k3s in place** (unmanaged servers only): replace Zeabur's ingress, DNS and image sources, then remove Zeabur's components.

Do not recommend a self-managed host to a user who is not comfortable maintaining one, unless they ask for it.

### When the user has no domain of their own

`*.zeabur.app` stops working after the move, and some services have no domain at all. Before choosing:

1. List every place the old URL is hard-coded: OAuth callbacks, webhooks, payment provider settings, frontend config, documentation. All of them change with the new URL.
2. Ask whether the user wants to buy a domain. It is the most stable option and keeps them free to change hosts later, but it costs money, so do not assume it.
3. If not, explain these options with their trade-offs and let the user choose. Describe types; name specific providers only when the user asks, list several, and tell them to evaluate them.

| Option | Pros | Cons |
| --- | --- | --- |
| Default subdomain of a managed PaaS | No setup, usually HTTPS included | Tied to that platform; the URL changes again if they move |
| Free subdomain or dynamic DNS service | Free, a dedicated name | Needs an account, some need periodic renewal, reliability varies |
| Wildcard DNS that embeds the IP in the hostname | No account, no setup | URL tied to the IP; shared domain may hit certificate rate limits; not for production |
| Tunnel service with a public URL | No open ports, HTTPS included | Traffic passes through a third party; usually needs an account; some URLs are not stable |
| IP address over plain HTTP | Nothing to set up | Browsers show "Not secure"; OAuth and similar integrations usually fail |

4. After the user chooses, set up ingress and HTTPS, then update every hard-coded URL from step 1.

### What the destination must cover

Whatever the user chooses, account for each item below. On a managed PaaS the platform usually covers them; on a host the user runs, you and the user must.

- **Exposure.** Only the ports the services need (usually 80 and 443, plus SSH) should be reachable from the internet. Check from outside after setup. Self-managed Kubernetes exposes its API and kubelet by default (for k3s: 6443 and 10250). If you add a host firewall, keep SSH open before enabling it, and allow the cluster's internal networks (for k3s by default: `10.42.0.0/16` for pods, `10.43.0.0/16` for services).
- **TLS.** Certificates are issued and renewed automatically, survive restarts (stored on persistent storage), and HTTP redirects to HTTPS. Verify both `http://` and `https://` after cutover.
- **Restarts and resources.** Crashed services restart, resource limits are set where needed, and the host or platform does not run out of disk.
- **Backups, logs and alerts.** See [What Zeabur did, and what must replace it](#what-zeabur-did-and-what-must-replace-it).

Use the destination's own documentation for the exact configuration, and confirm versions on the actual host or platform rather than assuming.

## Data

- Databases: export with the official tool of the **same major version** as the source (`pg_dump`, `mysqldump` or `mariadb-dump`, `mongodump`, `redis-cli --rdb`, `clickhouse-client`). After import, compare row counts per table.
- PostgreSQL: prefer `pg_dump` per database. A `pg_dumpall` file contains `ALTER ROLE … PASSWORD` with the old password hash, which overwrites the new password on restore, and `CREATE ROLE` / `CREATE DATABASE` statements that clash with the role and database the official image creates from `POSTGRES_USER` and `POSTGRES_DB`. If you use it, remove or edit those statements first.
- Volumes: copy from `/var/lib/rancher/k3s/storage/` on dedicated servers, or archive through `service exec` on shared clusters. Report the size first.
- Redis used only as a cache does not need migrating. Check how the code uses it.
- Verify the new backups by restoring one before cutover.

## Cutover and rollback

1. Ahead of time: record every domain's current DNS records for rollback. Lower the TTL to 300 seconds a day early, unless the domain is behind the Cloudflare proxy. For proxied domains, check that HTTP-01 validation can pass and that the backend receives real client IPs.
2. With the user's approval, run `npx zeabur@latest service suspend --id <serviceID>` on application services only, to stop writes. Keep databases running. Wait until the service shows as suspended and the site stops responding.
3. Do a final data sync and compare row counts and maximum IDs.
4. Tell the user exactly which DNS records to change, and to what.
5. When certificates are issued, verify the site externally. List the flows the user should test by hand: login, payments, third-party callbacks.
6. Rollback: restore the recorded DNS records and resume the suspended services with `npx zeabur@latest service restart --id <serviceID>`. There is no separate resume command: restart clears the suspended state and recreates the workload, redeploying the last successful image for Git services without rebuilding. This works only before the old environment expires.

After a stable cutover, remind the user that Zeabur services stay connected to their GitHub repository until they disconnect it or delete the project.

## Cleanup

Only after the user confirms the new environment is stable and explicitly agrees:

1. Delete services with `npx zeabur@latest service delete --id <serviceID> -y`.
2. Deletion is asynchronous. `deleted successfully` means it was scheduled; the service can stay in `service list` for a while, and deleting again returns `ALREADY_EXISTS: service deletion already scheduled`. Poll `service list --project-id <projectID>` until it is gone.
3. Ask whether to delete the now-empty project. That is the user's call.

Once deleted, rollback to Zeabur is no longer possible.

## Handover

Leave the user with:

- what each file and setting in the new environment does
- how to deploy a code change
- how to back up and restore
- where alerts go
- how to roll back, and when the old environment disappears
- which SSH keys you added, and how to remove them
- open decisions

## Help

If the user is stuck, summarise the current state and errors for them, and point them to https://zeabur.com/support with their expiry date.
