---
title: "Migrate VMware VMs to STACKIT with Coriolis"
description: "Migrate VMware ESXi virtual machines to STACKIT with Coriolis: configure endpoints, transfer disks, test the target, synchronize changes, and execute cutover."
scfAsset:
  managed: false
  category: "guide"
  external: false
  tags: ["design-and-mobilize", "use-cases", "relocate", "vmware", "coriolis", "spring-boot", "postgresql", "automation"]
  maintainers:
    - user: "lukas.weberruss"
      role: true
      website: true
source_url: "https://framework.stackit.cloud/migration/assetcontainer/stackit/relocate-automation-vmware-coriolis/"
source_file: "docs/migration/assetcontainer/stackit/relocate-automation-vmware-coriolis.mdx"
---

## Understand the migration architecture

Use Coriolis to copy VMware VM disks to STACKIT, adapt the guest operating system to the target
virtual hardware, and start the migrated VM in a STACKIT project. The application and database
remain inside the VM. This is a Relocate migration, not an application rebuild or a move to a
managed database.

Follow the sequence in this guide: prepare source and target, connect Coriolis to both platforms,
transfer disks, test an isolated target, synchronize changes, and perform the final cutover.
The API examples use `curl` and `jq`; they do not require the example repository's Python helpers.

### Separate disk transfer from VM deployment

Coriolis separates **moving disk data** from **creating a destination VM**. A Transfer defines
the source and target settings. Each Execution writes the initial disk contents or subsequent
changed blocks to destination volumes. Completing that copy does not start an application VM.

A Deployment consumes the transferred disk state, clones it for this guide's rehearsal/final
servers, adapts the guest OS and creates a new STACKIT server. It recreates the workload on new
virtual hardware; it does not move the original ESXi VM object or copy its CPU/RAM hardware
unchanged. Source sizing is the input to an explicit STACKIT machine-type mapping.

```d2
direction: right
source: "VMware source" {
  grid-columns: 1
  vm: "Running VM\nApplication + database"
  disks: "Source VMDKs" { shape: cylinder }
}
transfer: "1. Disk transfer" {
  grid-columns: 1
  copy: "Initial copy + delta Executions"
  volumes: "Synchronized STACKIT volumes" { shape: cylinder }
}
deployment: "2. VM deployment" {
  grid-columns: 1
  clones: "Cloned deployment volumes" { shape: cylinder }
  morphing: "Adapt guest OS / VirtIO / boot"
  vm: "New STACKIT VM\nMapped CPU + RAM, NICs, firmware"
}
source -> transfer: "Read source disks"
transfer -> deployment: "Deployment request"
```

| Phase | Input | Result | What remains separate |
| --- | --- | --- | --- |
| Transfer Execution | Source VM disks and the Transfer definition. | Copied/synchronized destination volumes and integrity evidence. | Final VM creation, guest acceptance and traffic switch. |
| Deployment | Completed transferred disk state and approved target mappings. | A new server with attached deployment volumes, target NICs and adapted guest OS. | Application/data acceptance and production cutover. |

An existing cloned rehearsal is a point-in-time test VM. Later Executions update the transferred
volumes, not that rehearsal's disks. Create a fresh Deployment to test a newer synchronization.
With `auto_deploy: false`, request each disk synchronization and VM creation explicitly.

### Migration objects and roles

| Term | Meaning |
| --- | --- |
| Coriolis appliance | The installed Coriolis control plane, including its web interface, identity service, migration API and workers. |
| Provider | The Coriolis adapter for a platform: `vmware_vsphere` for VMware and `stackit` for STACKIT. |
| Endpoint | A saved connection to a source or destination platform, including credentials and provider settings. |
| Coriolis project | The Coriolis authorization scope containing endpoints and migration jobs. It is not a STACKIT project. |
| Keystone | The appliance's identity API. It issues a token scoped to the selected Coriolis project. |
| Coriolis worker region | A logical group of Coriolis workers selected for endpoint access. It is separate from the STACKIT cloud region. |
| STACKIT project | The cloud resource scope containing the destination network, servers and volumes. |
| Transfer | The migration definition: source VM IDs, destination endpoint, network mapping and disk-transfer settings. |
| Execution | One run of a Transfer. The initial run copies the disks; later runs synchronize changed blocks. |
| Deployment | An operation that creates a destination server from transferred disks. |
| Migration worker | A Coriolis process or temporary VM that reads, transfers or adapts disks. It is not the migrated application server. |
| OS morphing | Adaptation of the guest's drivers, boot and network configuration to the destination platform. |
| CBT | VMware Changed Block Tracking, used to identify disk blocks modified since an earlier synchronization. |
| NFC/NBD | VMware's network disk-access path used during export; the Coriolis worker must reach the serving ESXi hosts. |
| Rehearsal | An isolated test deployment before production cutover. |
| Cutover | The final write stop, disk synchronization, target start, validation and traffic switch. |

The worked example uses `scf-relocate-app`: Ubuntu 24.04, Spring Boot and PostgreSQL 16 on one VM,
with a 12-GiB system disk and an 8-GiB database disk. Use the same Coriolis operations for other
supported VMs; select their own OS, capacity, networks and application acceptance tests.

### Prerequisites and safety boundaries

1. Deploy and license a Coriolis appliance with the VMware and STACKIT providers. Use the
  <LinkChip href="/migration/assetcontainer/stackit/coriolis-stackit-installer/">Coriolis STACKIT Installer</LinkChip>
   to deploy the appliance on STACKIT.
2. Check the source ESXi/vCenter version, guest OS and disk layout against the installed providers'
   support matrix. Use a VMware license that permits API snapshots, CBT and disk export.
3. Create a dedicated VMware migration account and a STACKIT service account. Grant the VMware
   account the required inventory, snapshot, change-tracking, disk-export and datastore privileges
   on the VMs and datastores being migrated. Grant the STACKIT account access to the destination
   project and permission to create and manage migration servers, volumes, NICs and security groups.
4. Prepare destination capacity, networking, quotas and an isolated rehearsal environment.
5. Define the maintenance window, application acceptance tests, traffic switch and rollback owner.
   Keep the source VM and its backups until the migration retention period ends.

The requests below use the Transfer/Deployment API profile of the Coriolis 2608.2 appliance and
its VMware/STACKIT providers. Retrieve the installed provider schemas before creating requests;
they define the accepted fields. This guide uses Transfers, not the separate Replica/DR workflow.

For Server Agent checks, activate **STACKIT Agent Service once per destination project**, then
install and provision the agent on the target guest. Project activation and guest provisioning
are separate operations. SSH-based validation does not require Server Agent.

### Architecture and network connections

```d2
direction: right
operator: "Operator"
vmware: "VMware environment" {
  management: "vCenter / ESXi"
  source: "Source VM: OS, application, database"
}
coriolis: "Coriolis appliance" {
  api: "REST API and scheduler"
  worker: "Coriolis worker"
}
stackit: "STACKIT destination project" {
  worker: "Temporary migration worker"
  disks: "Transferred volumes" { shape: cylinder }
  target: "Migrated application VM"
}
operator -> coriolis.api: "HTTPS: endpoints, transfers, deployments"
coriolis.api -> coriolis.worker: "Schedule tasks"
coriolis.worker -> vmware.management: "API 443 and disk export 902"
vmware.management -> vmware.source: "Snapshots and disk access"
coriolis.worker -> stackit.worker: "SSH 22 and HTTPS transfer 5566"
stackit.worker -> stackit.disks: "Write transfer disks"
stackit.disks -> stackit.target: "Clone, morph and deploy"
```

| Connection | Required access | Purpose |
| --- | --- | --- |
| Operator to appliance | HTTPS on the configured web/API port | Authentication, configuration and task status. |
| Coriolis worker to vCenter/ESXi | TCP/443 | VMware inventory, snapshots and API operations. |
| Coriolis worker to source ESXi hosts | TCP/902 | NFC/NBD disk export; include every host that can serve the selected VM's disks. |
| Appliance/workers to STACKIT APIs | HTTPS/TCP/443 | Authenticate and create destination resources. |
| Coriolis worker to temporary STACKIT workers | TCP/22 and TCP/5566 for this HTTPS-transfer configuration | Worker management and disk transfer. |
| Target guest to platform services | Metadata, DNS and approved package/agent services | Guest initialization and operations. |

Route the Coriolis worker to the private destination migration network. For an appliance in the
same STACKIT project, use an attached network or approved routed connection. For an on-premises
appliance, provide a site-to-site VPN or private interconnect. A VPN supplies network reachability;
it is not a Coriolis migration mechanism. No specific VPN product or intermediate host is required.

Keep temporary workers private. Restrict worker access to Coriolis and operator access to approved
management sources. Do not connect rehearsal VMs to production traffic or identity-sensitive services.

### Prepare the workspace

Use Bash, `curl`, `jq` and trusted TLS certificates on the operator workstation. Install the
official STACKIT CLI for the optional Server Agent commands. Do not disable certificate checking.
Keep authentication files and API responses in a private directory, outside a Git repository.
Use a dedicated Bash session for operator commands; stop on a failed request rather than continuing
with an empty or stale identifier.

```bash
set -euo pipefail
umask 077
WORK="$HOME/coriolis-migration"
mkdir -p "$WORK"
chmod 700 "$WORK"
CORIOLIS_URL='https://coriolis.example.com'
CORIOLIS_USER='migration-operator'
CORIOLIS_PROJECT='admin'
CORIOLIS_PASSWORD_FILE="$HOME/.config/coriolis/password"
VMWARE_HOST='vcenter.example.com'
VMWARE_USER='migration-user@vsphere.local'
VMWARE_PASSWORD_FILE="$HOME/.config/coriolis/vmware-password"
STACKIT_KEY_FILE="$HOME/.config/stackit/service-account.json"
STACKIT_ORGANIZATION_ID='replace-with-organization-uuid'
STACKIT_PROJECT_ID='replace-with-project-uuid'
STACKIT_REGION='eu01'
STACKIT_AVAILABILITY_ZONE='eu01-1'
MIGRATION_NETWORK_ID='replace-with-migration-network-uuid'
TARGET_NETWORK_ID='replace-with-application-network-uuid'
TARGET_SECURITY_GROUP_ID='replace-with-application-security-group-uuid'
WORKER_IMAGE_ID='replace-with-ubuntu-worker-image-uuid'
WORKER_MACHINE_TYPE='c3i.2'
TARGET_MACHINE_TYPE='c3i.2'
SOURCE_NETWORK='VM Network'
```

Replace every example hostname, username and `replace-with-...` value before execution:

| Input | Value to supply | Where to obtain it |
| --- | --- | --- |
| `CORIOLIS_URL` | HTTPS appliance origin, without a trailing slash | The appliance deployment's web address. |
| `CORIOLIS_USER`, `CORIOLIS_PROJECT` | Coriolis login and authorized project name | Appliance administrator; `admin` is the example scope, not a required name. |
| `CORIOLIS_PASSWORD_FILE` | File containing the Coriolis password | Your secret store; create the local file with mode 600. |
| `VMWARE_HOST`, `VMWARE_USER`, `VMWARE_PASSWORD_FILE` | vCenter/ESXi hostname, migration account and mode-600 password file | VMware administrator and secret store. |
| `STACKIT_KEY_FILE` | Original STACKIT service-account key JSON, mode 600 | STACKIT service-account key creation or your secret store. |
| `STACKIT_ORGANIZATION_ID`, `STACKIT_PROJECT_ID` | Organization and destination project UUIDs | STACKIT Portal organization/project details. |
| `STACKIT_REGION`, `STACKIT_AVAILABILITY_ZONE` | Placement region and zone | Destination project configuration; this example uses `eu01` / `eu01-1`. |
| `MIGRATION_NETWORK_ID` | Network UUID for temporary workers | Destination project's Networks view or API. It must be reachable from Coriolis. |
| `TARGET_NETWORK_ID`, `TARGET_SECURITY_GROUP_ID` | Application network and security-group UUIDs | Destination project's Networks and Security Groups views or API. |
| `WORKER_IMAGE_ID` | Ubuntu worker image UUID with cloud-init | STACKIT image catalog; choose an image available to the destination project. |
| `WORKER_MACHINE_TYPE`, `TARGET_MACHINE_TYPE` | Worker and final server machine types | STACKIT machine-type catalog and sizing plan. `c3i.2` is the small example's choice. |
| `SOURCE_NETWORK` | Source VM's exact port-group/network identifier | VMware VM network-adapter configuration; `VM Network` is the example port group. |

The API calls below return `CORIOLIS_PROJECT_ID`, `WORKER_REGION_ID`, `SOURCE_ENDPOINT_ID`,
`TARGET_ENDPOINT_ID`, `VM_ID`, `TRANSFER_ID`, `EXECUTION_ID` and `DEPLOYMENT_ID`. Do not invent
these IDs or substitute the VM's display name. `TARGET_SERVER_ID` is the resulting STACKIT server UUID.
The example uses one destination network for workers and the isolated application; use distinct
network IDs when the landing-zone design separates them. Keep shell tracing and verbose HTTP
logging disabled around authentication.

## Prepare VMware and STACKIT

Migrate the existing guest; do not reinstall its OS, application or database as a preparation step.
Record VM identifiers, vCPU/RAM, firmware, disks, mount points, network adapters, guest accounts,
application services and data dependencies. Take and verify an application-consistent backup.

For the worked example, prepare this source inventory:

| Item | Example configuration |
| --- | --- |
| VM | `scf-relocate-app`, Ubuntu 24.04, 2 vCPU, 3 GiB RAM, EFI boot. |
| Application | Spring Boot on Java 21, systemd unit `relocate-demo.service`, HTTP on `127.0.0.1:8080`. |
| Database | PostgreSQL 16, cluster `relocate`, database `relocate`, port 5432. |
| System disk | 12 GiB, OS and application binaries. |
| Data disk | 8 GiB, UUID-mounted at `/srv/relocate-data`; PostgreSQL data at `/srv/relocate-data/postgresql`. |
| Test data | `migration_records` table; `/api/evidence` returns record counts and a content digest; POST `/api/writes` inserts one synthetic record. |
| Background writer | `relocate-writer.timer`; stop it during fixed-boundary comparisons. |

These service names, paths and application endpoints belong to this example. For another workload,
record its actual equivalents and use its business-level acceptance tests. Coriolis does not require
Spring Boot, PostgreSQL, a blank data disk or a particular VM name.

### Record the application data baseline

Record source application health and a consistency checkpoint before the initial copy. For the
dedicated example, stop its synthetic writer and capture independent SQL/HTTP evidence. Run these
commands inside the source guest, not on the appliance or operator workstation:

```bash
umask 077
GUEST_EVIDENCE="$HOME/migration-evidence"
mkdir -p "$GUEST_EVIDENCE"
sudo systemctl stop relocate-writer.timer relocate-writer.service
sudo -u postgres psql -d relocate -At -F '|' -c \
  "SELECT count(*), count(*) FILTER (WHERE kind='seed'), count(*) FILTER (WHERE kind='write'),
    md5(string_agg(record_id || ':' || payload, E'\n' ORDER BY record_id)) FROM migration_records;" \
  > "$GUEST_EVIDENCE/data.txt"
curl --fail --silent --show-error http://127.0.0.1:8080/api/evidence | jq -S \
  > "$GUEST_EVIDENCE/api.json"
findmnt -n -o UUID --target /srv/relocate-data > "$GUEST_EVIDENCE/data-uuid.txt"
sudo -u postgres psql -d relocate -Atc 'SHOW data_directory;'
```

`GUEST_EVIDENCE` is a private output directory on the guest. Preserve these source records without
overwriting them during target acceptance. Keep the example writer stopped through initial copy
and rehearsal. For production applications, define the backup/checkpoint and comparison method
with the application owner; account for writes made while an online copy runs.

### Qualify the licensed VMware source

Use the VMware client with the dedicated migration account to create and remove a test snapshot
on the selected VM. Verify datastore headroom, healthy snapshot consolidation and running VMware
Tools. Enable Changed Block Tracking (CBT) for the selected VM/disks through the approved VMware
procedure before the first Transfer. This guide sets `automatically_enable_cbt: false` because CBT
is prepared explicitly.

Confirm that the source VM has a stable identifier and appears in Coriolis inventory. Resolve
missing identifiers, unsupported versions or failed disk-export access before starting a copy.
Do not modify VM identity or patch provider libraries as a general migration step.

Prepare the destination project with the networks and security groups from the input table.
Reserve capacity for the transferred volumes, temporary workers and rehearsal/final servers.
Create a worker image with cloud-init, and confirm the chosen machine types and zone are available.

## Connect Coriolis to both platforms

Complete the routing and firewall connections from the architecture table before configuring
endpoints. Validate the paths from the Coriolis worker host, not only from the browser workstation.
The appliance web interface is a control-plane connection; it does not carry all disk traffic.

### Authenticate to the Coriolis API

Discover the appliance's identity and migration API paths. The configured origin is the same
HTTPS origin used for the web interface:

```bash
curl --fail --silent --show-error "$CORIOLIS_URL/api/config" > "$WORK/config.json"
IDENTITY_URL="$CORIOLIS_URL$(jq -er '.config.servicesUrls.keystone' "$WORK/config.json")"
CORIOLIS_API_URL="$CORIOLIS_URL$(jq -er '.config.servicesUrls.coriolis' "$WORK/config.json")"
USER_DOMAIN=$(jq -er '.config.defaultUserDomain' "$WORK/config.json")
```

Build the password-authentication request from the protected password file and request an
unscoped Keystone token. `X-Subject-Token` is the response header containing the token:

```bash
jq -n --arg username "$CORIOLIS_USER" --arg domain "$USER_DOMAIN" \
  --rawfile password "$CORIOLIS_PASSWORD_FILE" \
  '{auth:{identity:{methods:["password"],password:{user:{name:$username,
    password:($password|rtrimstr("\n")),domain:{name:$domain}}}},scope:"unscoped"}}' \
  > "$WORK/login.json"
curl --fail --silent --show-error -X POST "$IDENTITY_URL/auth/tokens" \
  -H 'Content-Type: application/json' --data-binary @"$WORK/login.json" \
  -D "$WORK/unscoped.headers" -o "$WORK/unscoped.json"
UNSCOPED_TOKEN=$(awk 'tolower($1)=="x-subject-token:" {gsub("\r","",$2); print $2}' "$WORK/unscoped.headers")
printf 'X-Auth-Token: %s\n' "$UNSCOPED_TOKEN" > "$WORK/unscoped-request.headers"
curl --fail --silent --show-error -H @"$WORK/unscoped-request.headers" \
  "$IDENTITY_URL/auth/projects" > "$WORK/projects.json"
CORIOLIS_PROJECT_ID=$(jq -er --arg project "$CORIOLIS_PROJECT" \
  '[.projects[]|select(.name==$project)]|if length==1 then .[0].id else error("Select one authorized Coriolis project") end' \
  "$WORK/projects.json")
```

Scope the token to that Coriolis project. Store the resulting header privately and use it for
every migration API request. The token in the JSON request is read from a file, not a command-line argument:

```bash
jq -n --rawfile token "$WORK/unscoped-request.headers" --arg project "$CORIOLIS_PROJECT_ID" \
  '{auth:{identity:{methods:["token"],token:{id:($token|sub("^X-Auth-Token: ";"")|rtrimstr("\n"))}},
    scope:{project:{id:$project}}}}' > "$WORK/scope.json"
curl --fail --silent --show-error -X POST "$IDENTITY_URL/auth/tokens" \
  -H 'Content-Type: application/json' --data-binary @"$WORK/scope.json" \
  -D "$WORK/scoped.headers" -o "$WORK/scoped.json"
SCOPED_TOKEN=$(awk 'tolower($1)=="x-subject-token:" {gsub("\r","",$2); print $2}' "$WORK/scoped.headers")
printf 'X-Auth-Token: %s\n' "$SCOPED_TOKEN" > "$WORK/coriolis.headers"
API="$CORIOLIS_API_URL/$CORIOLIS_PROJECT_ID"
unset UNSCOPED_TOKEN SCOPED_TOKEN
```

`API` is the project-scoped migration API base, for example
`https://coriolis.example.com/coriolis/<coriolis-project-id>`. A `401` response requires renewed
authentication; verify the existing operation's status before resubmitting a request.

### Read provider schemas and select workers

```bash
curl --fail --silent --show-error -H @"$WORK/coriolis.headers" "$API/providers" > "$WORK/providers.json"
curl --fail --silent --show-error -H @"$WORK/coriolis.headers" "$API/regions" > "$WORK/regions.json"
jq '.regions[]|{id,name,enabled}' "$WORK/regions.json"
WORKER_REGION_ID='replace-with-enabled-coriolis-worker-region-id'
curl --fail --silent --show-error -H @"$WORK/coriolis.headers" \
  "$API/providers/vmware_vsphere/schemas/16" > "$WORK/vmware-connection-schema.json"
curl --fail --silent --show-error -H @"$WORK/coriolis.headers" \
  "$API/providers/stackit/schemas/16" > "$WORK/stackit-connection-schema.json"
curl --fail --silent --show-error -H @"$WORK/coriolis.headers" \
  "$API/providers/vmware_vsphere/schemas/8" > "$WORK/source-environment-schema.json"
curl --fail --silent --show-error -H @"$WORK/coriolis.headers" \
  "$API/providers/stackit/schemas/4" > "$WORK/destination-environment-schema.json"
```

Select the enabled Coriolis region whose workers have the required source/destination access,
and replace `WORKER_REGION_ID` with its returned ID. A region named `Public` is a logical Coriolis
worker group; the name does not enable public IPs. `STACKIT_REGION` still selects the destination
cloud region. In this API, schema selector `16` returns connection settings, `8` the VMware source
environment, and `4` the STACKIT destination environment. Validate request files against their
returned JSON Schemas before submitting them.

### Create and validate platform endpoints

Create the VMware endpoint. `host` is the vCenter or supported standalone ESXi address, not the
guest IP. The account must see every selected VM and the associated datastores:

```bash
jq -n --arg host "$VMWARE_HOST" --arg username "$VMWARE_USER" \
  --rawfile password "$VMWARE_PASSWORD_FILE" --arg worker "$WORKER_REGION_ID" \
  '{endpoint:{name:"vmware-source",type:"vmware_vsphere",mapped_regions:[$worker],
    connection_info:{host:$host,port:443,username:$username,
      password:($password|rtrimstr("\n")),allow_untrusted:false}}}' > "$WORK/vmware-endpoint-request.json"
curl --fail --silent --show-error -X POST -H @"$WORK/coriolis.headers" \
  -H 'Content-Type: application/json' --data-binary @"$WORK/vmware-endpoint-request.json" \
  "$API/endpoints" > "$WORK/vmware-endpoint.json"
SOURCE_ENDPOINT_ID=$(jq -er '.endpoint.id' "$WORK/vmware-endpoint.json")
```

Create the STACKIT endpoint. The provider's `service_account_key` field contains the base64
representation of the original key JSON. Base64 is encoding, not encryption; protect this request file:

```bash
jq -n --arg organization "$STACKIT_ORGANIZATION_ID" --arg project "$STACKIT_PROJECT_ID" \
  --arg region "$STACKIT_REGION" --arg worker "$WORKER_REGION_ID" --rawfile key "$STACKIT_KEY_FILE" \
  '{endpoint:{name:"stackit-destination",type:"stackit",mapped_regions:[$worker],
    connection_info:{organization_id:$organization,project_id:$project,
      region_name:$region,service_account_key:($key|@base64)}}}' > "$WORK/stackit-endpoint-request.json"
curl --fail --silent --show-error -X POST -H @"$WORK/coriolis.headers" \
  -H 'Content-Type: application/json' --data-binary @"$WORK/stackit-endpoint-request.json" \
  "$API/endpoints" > "$WORK/stackit-endpoint.json"
TARGET_ENDPOINT_ID=$(jq -er '.endpoint.id' "$WORK/stackit-endpoint.json")
```

Validate both connections and require `valid: true`. Then refresh source inventory:

```bash
for ENDPOINT_ID in "$SOURCE_ENDPOINT_ID" "$TARGET_ENDPOINT_ID"; do
  curl --fail --silent --show-error -X POST -H @"$WORK/coriolis.headers" \
    -H 'Content-Type: application/json' --data-binary '{"validate-connection":null}' \
    "$API/endpoints/$ENDPOINT_ID/actions" > "$WORK/validate-$ENDPOINT_ID.json"
  jq -e '.["validate-connection"].valid==true' "$WORK/validate-$ENDPOINT_ID.json"
done
curl --fail --silent --show-error -H @"$WORK/coriolis.headers" \
  "$API/endpoints/$SOURCE_ENDPOINT_ID/instances?refresh=true&limit=100" > "$WORK/source-instances.json"
jq '.instances[]|{id,name,os_type,power_state}' "$WORK/source-instances.json"
VM_ID='replace-with-selected-instance-id-from-coriolis'
```

Select the exact returned VM ID, cross-check it against the source inventory, and replace `VM_ID`.
For an estate larger than this inventory page, retrieve all pages before selection. Save every
returned resource ID; after an interrupted POST, inspect the API's existing endpoints/jobs instead
of creating duplicates. Endpoint validation confirms credentials/API access, not complete disk-path reachability.

## Build and synchronize target disks

Create one Transfer definition for the selected VM. The source VM remains running during the
initial copy; the maintenance window belongs to the final synchronization and traffic switch.
Set the disk-transfer policy and the later Deployment's target mapping now. These settings belong
to one migration definition, but a Transfer Execution and a Deployment remain separate operations.
The expected result of this phase is synchronized target volumes, not a booted application server.

```bash
jq -n --arg source "$SOURCE_ENDPOINT_ID" --arg destination "$TARGET_ENDPOINT_ID" --arg vm "$VM_ID" \
  --arg project "$STACKIT_PROJECT_ID" --arg source_network "$SOURCE_NETWORK" \
  --arg target_network "$TARGET_NETWORK_ID" --arg migration_network "$MIGRATION_NETWORK_ID" \
  --arg security_group "$TARGET_SECURITY_GROUP_ID" --arg image "$WORKER_IMAGE_ID" \
  --arg worker_type "$WORKER_MACHINE_TYPE" --arg target_type "$TARGET_MACHINE_TYPE" \
  --arg zone "$STACKIT_AVAILABILITY_ZONE" \
  '{transfer:{scenario:"live_migration",origin_endpoint_id:$source,destination_endpoint_id:$destination,
    instances:[$vm],source_environment:{export_transfer_mechanism:"openvixdisklib",
      automatically_enable_cbt:false,verify_disk_integrity:true,skip_nfc_validation:false},
    destination_environment:{project:$project,network_map:{($source_network):$target_network},
      migr_network:$migration_network,migr_machine_type:$worker_type,machine_type:$target_type,
      availability_zone:$zone,migr_image_map:{linux:$image},set_dhcp:true,
      migr_worker_use_public_ip:false,use_public_ip:false,preserve_fixed_ips:false,
      retain_user_credentials:true,security_groups:[$security_group],data_transfer_mechanism:"HTTPS",
      volumes_are_zeroed:false,delete_disks_on_server_termination:false},
    network_map:{($source_network):$target_network},clone_disks:true,skip_os_morphing:false}}' \
  > "$WORK/transfer-request.json"
curl --fail --silent --show-error -X POST -H @"$WORK/coriolis.headers" \
  -H 'Content-Type: application/json' --data-binary @"$WORK/transfer-request.json" \
  "$API/transfers" > "$WORK/transfer.json"
TRANSFER_ID=$(jq -er '.transfer.id' "$WORK/transfer.json")
```

| Setting | Effect |
| --- | --- |
| `instances` | Exact VM IDs from the source endpoint's inventory. Start with one representative VM. |
| `network_map` | Source port group to destination network mapping. Add a mapping for every source network used by the selected VMs. |
| `migr_network`, `migr_machine_type`, `migr_image_map` | Placement and image of temporary workers, not of the final application server. |
| `machine_type`, `availability_zone`, `security_groups` | Final server sizing, zone and application security groups. |
| `openvixdisklib` | The VMware disk-export mechanism used in this example. Select an export mechanism supported by the installed provider and source platform. |
| `verify_disk_integrity: true` | Enables end-to-end disk checksum verification. |
| `skip_nfc_validation: false` | Keeps ESXi disk-path connectivity validation enabled. |
| `migr_worker_use_public_ip: false`, `use_public_ip: false` | Keeps workers and final servers private. |
| `set_dhcp: true`, `preserve_fixed_ips: false` | Configures target DHCP; it does not retain the source address or MAC. |
| `retain_user_credentials: true` | Retains existing guest users/credentials; enforce the target SSH policy explicitly during acceptance. |
| `clone_disks: true`, `skip_os_morphing: false` | Uses cloned disks for deployment and adapts the guest to the destination hardware. |

This request configures Linux guests. For Windows, configure the provider's Windows worker/image
and VirtIO-driver settings and use Windows guest checks. Use separate Transfers for VMs needing
different sizing or guest policies. For a migration wave, include all selected IDs in `instances`
and coordinate the application dependency group's final write stop.

### Start and inspect a disk synchronization

Create an Execution of that Transfer. `shutdown_instances: false` leaves source power under your
control; `auto_deploy: false` separates disk copy from target startup:

```bash
curl --fail --silent --show-error -X POST -H @"$WORK/coriolis.headers" \
  -H 'Content-Type: application/json' \
  --data-binary '{"execution":{"shutdown_instances":false,"auto_deploy":false}}' \
  "$API/transfers/$TRANSFER_ID/executions" > "$WORK/initial-execution.json"
EXECUTION_ID=$(jq -er '.execution.id' "$WORK/initial-execution.json")
curl --fail --silent --show-error -H @"$WORK/coriolis.headers" \
  "$API/transfers/$TRANSFER_ID/executions/$EXECUTION_ID?include_task_info=true" \
  > "$WORK/initial-status.json"
jq '.execution|{id,status,tasks:[.tasks[]|{task_type,status}]}' "$WORK/initial-status.json"
```

Repeat the GET status request until the Execution is terminal. Proceed only when the execution,
disk replication and both `DELETE_TRANSFER_SOURCE_RESOURCES` / `DELETE_TRANSFER_TARGET_RESOURCES`
tasks are `COMPLETED`. These cleanup tasks remove temporary transfer resources, not the source VM.
Resolve an `ERROR` and its cleanup before scheduling another Execution of the same Transfer.
For a lost POST response, list the Transfer's executions and recover the returned ID; do not repeat
the POST without reconciling the result.

## Create a new target VM

Turn the completed transferred disk state into a **new** STACKIT server. The Deployment creates
server/NIC/volume resources, prepares boot and guest drivers through OS morphing, and starts the
guest. The source ESXi VM remains a separate object with its own identity and power state.

### Map source configuration to STACKIT resources

Use the source inventory as the starting point, then approve the mapping before Deployment:

| Source input | STACKIT mapping | Example |
| --- | --- | --- |
| vCPU and RAM | Select a machine type with the approved capacity; do not assume identical size names or memory ratios. | Source: 2 vCPU / 3 GiB RAM. Target: the selected `c3i.2` catalog configuration. Verify its actual CPU/RAM capacity. |
| System and data disks | Map each disk to a destination volume with adequate capacity and storage performance. | Preserve separate 12-GiB system and 8-GiB data source disks; accept and record actual target volume sizes. |
| BIOS/EFI and guest OS | Use a supported target boot configuration and perform OS morphing. | EFI Ubuntu guest with VirtIO disks/networking. |
| Source network adapters | Create target NICs on the mapped networks with the approved security groups. | `VM Network` maps to `TARGET_NETWORK_ID`; this guide uses DHCP rather than the source IP/MAC. |
| Existing users and services | Apply the access/identity policy and validate the relocated guest workload. | Keep required users and Spring Boot/PostgreSQL services; enforce target key-only SSH. |

`machine_type` specifies the final server, while `migr_machine_type` sizes only temporary workers.
When `machine_type` is omitted, the STACKIT provider selects a minimum viable type based on the
source resource requirements. This guide supplies `TARGET_MACHINE_TYPE` explicitly so the
deployment uses a reviewed mapping. Moving the disks does not by itself approve a target size.

### Rehearse an isolated Deployment

Before the copy, record an application data baseline on the source. Keep the example's synthetic
writer stopped until this rehearsal is validated. A continuously written production database
requires its own consistency/rehearsal procedure; an online disk copy is not a replacement for
application-consistent backup or coordinated final quiescence.

Use `clone_disks: true` to create a test server from separate deployment volumes while retaining
the transferred volumes for subsequent synchronization. Keep the server isolated from production
traffic. Create the Deployment only after the disk-transfer and cleanup checks have passed:

```bash
jq -n --arg transfer "$TRANSFER_ID" \
  '{deployment:{transfer_id:$transfer,clone_disks:true,force:false,skip_os_morphing:false}}' \
  > "$WORK/deployment-request.json"
curl --fail --silent --show-error -X POST -H @"$WORK/coriolis.headers" \
  -H 'Content-Type: application/json' --data-binary @"$WORK/deployment-request.json" \
  "$API/deployments" > "$WORK/rehearsal-deployment.json"
DEPLOYMENT_ID=$(jq -er '.deployment.id' "$WORK/rehearsal-deployment.json")
curl --fail --silent --show-error -H @"$WORK/coriolis.headers" \
  "$API/deployments/$DEPLOYMENT_ID?include_info=true&include_task_info=true" \
  > "$WORK/rehearsal-status.json"
jq '.deployment|{id,last_execution_status,tasks:[.tasks[]|{task_type,status}]}' "$WORK/rehearsal-status.json"
```

Require `last_execution_status: COMPLETED` and completed morphing, finalization and cleanup tasks.
`force: false` prevents forced deployment; cloned disks keep transferred disk state available for
later synchronization. The rehearsal is a separate target, not the production traffic switch.

Retrieve the created NIC/volume identifiers:

```bash
jq --arg vm "$VM_ID" \
  '.deployment.info[$vm].instance_deployment_info|{instance_name,nic_ids,volumes_info}' \
  "$WORK/rehearsal-status.json"
```

In STACKIT, match those NIC and volume IDs to the resulting server and record its UUID as
`TARGET_SERVER_ID`. Names are not unique across source, rehearsal and final deployments; always
match resource IDs. Verify its private address, network, security groups and disks before login.
Obtain its SSH host-key fingerprint through a trusted management channel; the source VM's
fingerprint is not a target host-key pin.

## Validate the target VM

Compare the source inventory with the exact target. Run guest commands through the approved
private SSH/console path or through a provisioned STACKIT Server Agent. Keep the rehearsal
isolated from production clients, scheduled writers and identity-sensitive integrations.

For Linux guests, start with these standard checks:

```bash
systemd-detect-virt
uname -r
test -d /sys/firmware/efi && printf 'EFI boot\n'
lsblk -o NAME,SIZE,FSTYPE,UUID,MOUNTPOINTS
findmnt
ip -br address
ip route
systemctl --failed --no-pager
getent passwd
getent group
ls -l /sys/class/block/vd*/device/driver /sys/class/net/*/device/driver
```

| Check | Acceptance criterion |
| --- | --- |
| Virtual hardware | Target runs on KVM; active disk/NIC bindings use the target's VirtIO drivers, not VMware devices. Inspect bindings, not only the list of loaded modules. |
| Firmware and storage | Boot mode matches the planned target; all disks, UUID mounts and application data directories are present. Reconcile actual target sizes with the sizing plan. |
| VMware Tools | VMware Tools binaries/services are removed from the target. Package configuration remnants are not running tools; do not purge unrelated kernel modules. |
| Network | Target has the intended DHCP address, route, DNS and security groups; no stale VMware interface configuration breaks connectivity. |
| Users and access | Required usernames, numeric UIDs/GIDs, groups, sudo policy and authorized-key fingerprints match the access plan. Do not collect password hashes or private keys. |
| Identity | Document host-key changes and machine/monitoring identity. Keep parallel rehearsal identities isolated; regenerate identity only through the workload's approved procedure. |
| Services | Required application/database services are healthy; no unexplained failed units remain. |

Enforce the approved SSH policy after `retain_user_credentials` processing. For the Ubuntu example,
keep the current administrative session open, apply a key-only policy and validate before reload:

```bash
printf 'PasswordAuthentication no\n' | sudo tee /etc/ssh/sshd_config.d/00-migration-ssh.conf >/dev/null
printf 'ssh_pwauth: false\n' | sudo tee /etc/cloud/cloud.cfg.d/99-migration-ssh.cfg >/dev/null
sudo systemctl daemon-reload
sudo systemctl start ssh
sudo /usr/sbin/sshd -t
sudo /usr/sbin/sshd -T | grep -E '^(passwordauthentication|pubkeyauthentication) '
sudo systemctl reload ssh
```

Require `passwordauthentication no` and `pubkeyauthentication yes`, then test a fresh key-based
login from the approved operator path. Resolve any conflicting effective policy before proceeding.
For the Java example's unit, configure `SuccessExitStatus=143` so a normal SIGTERM stop is treated
as successful; do not restart the application just to clear an unexplained failure.

### Accept application and database data

For every application, verify its service health, read/write behavior, data consistency and
external dependencies. Use the source's recorded data boundary, not the rehearsal's own data
as its expected result. Keep acceptance writes on the isolated rehearsal separate from the
source-of-truth comparison.

On the example's target guest, repeat the SQL, HTTP and mount checks from the recorded source
baseline. These commands run inside the target guest, not on the appliance/operator workstation:

```bash
umask 077
GUEST_EVIDENCE="$HOME/migration-evidence"
mkdir -p "$GUEST_EVIDENCE"
sudo -u postgres psql -d relocate -At -F '|' -c \
  "SELECT count(*), count(*) FILTER (WHERE kind='seed'), count(*) FILTER (WHERE kind='write'),
    md5(string_agg(record_id || ':' || payload, E'\n' ORDER BY record_id)) FROM migration_records;" \
  > "$GUEST_EVIDENCE/data.txt"
curl --fail --silent --show-error http://127.0.0.1:8080/api/evidence | jq -S \
  > "$GUEST_EVIDENCE/api.json"
findmnt -n -o UUID --target /srv/relocate-data > "$GUEST_EVIDENCE/data-uuid.txt"
sudo -u postgres psql -d relocate -Atc 'SHOW data_directory;'
```

Collect the source/target records into separate operator evidence folders; do not overwrite the baseline.
Require identical record/seed/write counts, content digest and data mount UUID. Confirm PostgreSQL
uses `/srv/relocate-data/postgresql`, and verify `relocate-demo.service` and
`postgresql@16-relocate.service` are active. For example, ten additional source writes change a
1,004-record baseline to 1,014; use your captured counts rather than a fixed number from this guide.

Verify Server Agent separately from guest package presence. In the operator shell, authenticate
the official CLI with `STACKIT_KEY_FILE`, set `TARGET_SERVER_ID` to the matched server UUID,
and submit a read-only command:

```bash
TARGET_SERVER_ID='replace-with-matched-stackit-server-uuid'
stackit auth activate-service-account --service-account-key-path "$STACKIT_KEY_FILE"
stackit server command create --server-id "$TARGET_SERVER_ID" --project-id "$STACKIT_PROJECT_ID" \
  --region "$STACKIT_REGION" --template-name RunShellScript \
  --params 'script=id -u; uname -r; systemd-detect-virt' \
  --assume-yes --output-format json > "$WORK/agent-command.json"
AGENT_COMMAND_ID=$(jq -er '.id' "$WORK/agent-command.json")
stackit server command describe "$AGENT_COMMAND_ID" --server-id "$TARGET_SERVER_ID" \
  --project-id "$STACKIT_PROJECT_ID" --region "$STACKIT_REGION" --output-format json
```

Require completed status and exit code 0. `AGENT_COMMAND_ID` is the returned command ID, not a
server identifier. Confirm monitoring ingestion, backup scope and a recovery test before handoff.

## Synchronize source changes

Use another Execution of the **same Transfer** to synchronize source changes. Complete all
earlier copy/deployment tasks before starting it. A new Transfer is not required for every delta.
An existing cloned rehearsal does not receive these changes; deploy fresh cloned disks to test
the new boundary.

For the dedicated example VM, prove the delta with one batch of ten synthetic writes. First
confirm the console/session belongs to the recorded VMware source, not a same-name STACKIT clone;
`systemd-detect-virt` must identify VMware. Keep the background writer stopped. Do not insert
synthetic records into a production application:

```bash
for WRITE_NUMBER in {1..10}; do
  curl --fail --silent --show-error -X POST http://127.0.0.1:8080/api/writes || exit 1
done
curl --fail --silent --show-error http://127.0.0.1:8080/api/evidence | jq -S
```

Verify exactly ten extra records, unchanged seed data and SQL/API agreement. Record the new source
count/digest. Do not repeat an uncertain write batch; compare counts first. For a production VM,
use its normal workload changes and application consistency checks instead.

In the operator shell, synchronize the disks and inspect that exact new Execution:

```bash
curl --fail --silent --show-error -X POST -H @"$WORK/coriolis.headers" \
  -H 'Content-Type: application/json' \
  --data-binary '{"execution":{"shutdown_instances":false,"auto_deploy":false}}' \
  "$API/transfers/$TRANSFER_ID/executions" > "$WORK/delta-execution.json"
EXECUTION_ID=$(jq -er '.execution.id' "$WORK/delta-execution.json")
curl --fail --silent --show-error -H @"$WORK/coriolis.headers" \
  "$API/transfers/$TRANSFER_ID/executions/$EXECUTION_ID?include_task_info=true" \
  > "$WORK/delta-status.json"
jq '.execution|{id,status,tasks:[.tasks[]|{task_type,status}]}' "$WORK/delta-status.json"
```

Require completed replication/checksums and both resource-cleanup tasks. Repeat the Deployment
request with `clone_disks: true`, save its new `DEPLOYMENT_ID`, and match its new STACKIT resource
IDs. Repeat guest/data acceptance against the updated source boundary. Retain or remove older
rehearsals according to the test plan; never use their stale data as the delta result.

## Final cutover and recovery gates

### Follow the migration timeline

The initial copy and test Deployment take place before the maintenance window. Final copy uses
the frozen source boundary; production traffic moves only after the final new VM passes acceptance.

```d2
direction: right
preparation: "Before the maintenance window" {
  grid-columns: 1
  initial: "1. Initial disk Transfer\nSource VM stays on"
  rehearsal: "2. New rehearsal VM\nCloned disks + OS morphing"
  delta: "3. Delta Executions\nValidate with fresh test VM"
}
window: "Maintenance window" {
  grid-columns: 1
  freeze: "4. Stop writers + source VM\nRecord final data boundary"
  copy: "5. Final disk synchronization\nNo application VM yet"
  deployment: "6. Create final new VM\nValidate OS + application + data"
}
operation: "Production and retention" {
  grid-columns: 1
  traffic: "7. Switch approved traffic\nEnable target writers"
  monitor: "8. Monitor and hand over\nKeep source off for recovery"
}
preparation -> window
window -> operation: "Acceptance passed"
```

### Execute the cutover sequence

Execute the approved maintenance plan in this order:

1. Stop client writes, background jobs and dependent writers for the migration group.
2. Stop the source application, record the final database count/digest, and stop the database cleanly.
3. Shut down the source guest normally through VMware and verify actual `poweredOff` in vCenter/ESXi.
4. Run one final Execution of the same Transfer and require completed replication and cleanup.
5. Create the final Deployment, wait for all tasks, and resolve the exact final server/NIC/volume IDs.
6. Repeat OS, access, application and final-data acceptance on that server.
7. Switch the approved DNS/load-balancer/routing targets, enable target writers, and validate client traffic.
8. Monitor the application and retain the powered-off source for the agreed recovery period.

For the Ubuntu/PostgreSQL example, perform the source write stop inside the verified source guest:

```bash
sudo systemctl stop relocate-writer.timer relocate-writer.service relocate-demo.service
sudo -u postgres psql -d relocate -At -F '|' -c \
  "SELECT count(*), md5(string_agg(record_id || ':' || payload, E'\n' ORDER BY record_id)) FROM migration_records;"
sudo pg_ctlcluster --mode fast 16 relocate stop
sudo sync
```

Copy the final count/digest into the acceptance record before shutting down the source through
VMware's normal guest shutdown action. Verify the VM is off; a sent shutdown request is not proof.
Do not force power-off to skip a failed application/database stop.

In the operator shell, the final disk synchronization uses the same API operation as the earlier
delta, but the source is now off and the expected data boundary is fixed:

```bash
curl --fail --silent --show-error -X POST -H @"$WORK/coriolis.headers" \
  -H 'Content-Type: application/json' \
  --data-binary '{"execution":{"shutdown_instances":false,"auto_deploy":false}}' \
  "$API/transfers/$TRANSFER_ID/executions" > "$WORK/final-execution.json"
EXECUTION_ID=$(jq -er '.execution.id' "$WORK/final-execution.json")
curl --fail --silent --show-error -H @"$WORK/coriolis.headers" \
  "$API/transfers/$TRANSFER_ID/executions/$EXECUTION_ID?include_task_info=true" \
  > "$WORK/final-status.json"
jq '.execution|{id,status,tasks:[.tasks[]|{task_type,status}]}' "$WORK/final-status.json"
```

After final copy and cleanup are `COMPLETED`, repeat the Deployment POST with the same
`deployment-request.json`. Record and inspect the new final Deployment:

```bash
curl --fail --silent --show-error -X POST -H @"$WORK/coriolis.headers" \
  -H 'Content-Type: application/json' --data-binary @"$WORK/deployment-request.json" \
  "$API/deployments" > "$WORK/final-deployment.json"
DEPLOYMENT_ID=$(jq -er '.deployment.id' "$WORK/final-deployment.json")
curl --fail --silent --show-error -H @"$WORK/coriolis.headers" \
  "$API/deployments/$DEPLOYMENT_ID?include_info=true&include_task_info=true" \
  > "$WORK/final-deployment-status.json"
jq '.deployment|{id,last_execution_status,tasks:[.tasks[]|{task_type,status}]}' "$WORK/final-deployment-status.json"
```

Require completed Deployment tasks. Validate this final server against the frozen source count,
digest and mount UUID, not against an earlier rehearsal. Do not run concurrent copy/deployment
operations on the same Transfer.

Before enabling target writes, rollback means isolating/stopping the target, keeping traffic away
from it and resuming the retained source through the approved rollback plan. After target writes,
reconcile new target data before reverting; merely restarting the old VM loses those writes.
Delete the source only after the retention period and the application owner's release.

## Evidence and operating handoff

Hand operations the source/target inventory, endpoint/Transfer/Execution/Deployment IDs, final
server/NIC/volume IDs, completed task records, application/data acceptance, SSH/identity policy,
monitoring and backup/recovery configuration. Protect credential-bearing files and apply the
secret-retention policy; do not publish raw endpoint requests or authentication headers.

Remove temporary workers left by failed jobs only after correlating their ownership and task
state. Review security groups and resource costs. Record actual final volume capacity rather than
assuming it equals the source. For additional VMs, repeat the preparation/mapping/acceptance
process and migrate dependency-aligned waves through the same Coriolis API sequence.

<LinkCard
  title="Cloudbase Coriolis"
  href="https://cloudbase.it/coriolis/"
/>

<LinkCard
  title="Spring Boot and PostgreSQL example workload"
  href="https://github.com/stackitcloud/stackit-cmf-relocate-vmware-coriolis"
/>
