Skip to content
Beta

Relocate to STACKIT: Migrate VMware with Coriolis

Last updated on

Stackit LogoStackit Logo
STACKIT

Relocate to STACKIT: Migrate VMware with Coriolis

Migrate VMware VMs to STACKIT with Coriolis: prepare endpoints, synchronize target disks, deploy and validate a new VM, then complete cutover and operating handoff.

PLAN

Understand Transfer and Deployment

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.

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.

VMware source1. Disk transfer2. VM deploymentRunning VMApplication + databaseSource VMDKsInitial copy + delta ExecutionsSynchronized STACKIT volumesCloned deployment volumesAdapt guest OS / VirtIO / bootNew STACKIT VMMapped CPU + RAM, NICs, firmware Read source disksDeployment request

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.

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.

  1. Deploy and license a Coriolis appliance with the VMware and STACKIT providers. Use the Coriolis STACKIT Installer 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.

OperatorVMware environmentCoriolis applianceSTACKIT destination projectvCenter / ESXiSource VM: OS, application, databaseREST API and schedulerCoriolis workerTemporary migration workerTransferred volumesMigrated application VM HTTPS: endpoints, transfers, deploymentsSchedule tasksAPI 443 and disk export 902Snapshots and disk accessSSH 22 and HTTPS transfer 5566Write transfer disksClone, morph and deploy

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.

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.

Terminal window
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:

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.

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:

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 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:

Terminal window
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.

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.

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.

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

Terminal window
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:

Terminal window
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:

Terminal window
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.

Terminal window
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 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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

Terminal window
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")

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.

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

Terminal window
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.

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

Section titled “Map source configuration to STACKIT resources”

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

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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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

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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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.

Before the maintenance windowMaintenance windowProduction and retention1. Initial disk TransferSource VM stays on2. New rehearsal VMCloned disks + OS morphing3. Delta ExecutionsValidate with fresh test VM4. Stop writers + source VMRecord final data boundary5. Final disk synchronizationNo application VM yet6. Create final new VMValidate OS + application + data7. Switch approved trafficEnable target writers8. Monitor and hand overKeep source off for recovery Acceptance passed

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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

External source cloudbase.it Cloudbase Coriolis Open external site Leads off the trail Code & registry github.com Spring Boot and PostgreSQL example workload Open the repository
PLAN

Identify the Migration Objects

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.

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.

VMware source1. Disk transfer2. VM deploymentRunning VMApplication + databaseSource VMDKsInitial copy + delta ExecutionsSynchronized STACKIT volumesCloned deployment volumesAdapt guest OS / VirtIO / bootNew STACKIT VMMapped CPU + RAM, NICs, firmware Read source disksDeployment request

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.

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.

  1. Deploy and license a Coriolis appliance with the VMware and STACKIT providers. Use the Coriolis STACKIT Installer 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.

OperatorVMware environmentCoriolis applianceSTACKIT destination projectvCenter / ESXiSource VM: OS, application, databaseREST API and schedulerCoriolis workerTemporary migration workerTransferred volumesMigrated application VM HTTPS: endpoints, transfers, deploymentsSchedule tasksAPI 443 and disk export 902Snapshots and disk accessSSH 22 and HTTPS transfer 5566Write transfer disksClone, morph and deploy

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.

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.

Terminal window
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:

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.

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:

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 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:

Terminal window
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.

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.

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.

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

Terminal window
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:

Terminal window
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:

Terminal window
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.

Terminal window
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 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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

Terminal window
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")

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.

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

Terminal window
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.

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

Section titled “Map source configuration to STACKIT resources”

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

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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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

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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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.

Before the maintenance windowMaintenance windowProduction and retention1. Initial disk TransferSource VM stays on2. New rehearsal VMCloned disks + OS morphing3. Delta ExecutionsValidate with fresh test VM4. Stop writers + source VMRecord final data boundary5. Final disk synchronizationNo application VM yet6. Create final new VMValidate OS + application + data7. Switch approved trafficEnable target writers8. Monitor and hand overKeep source off for recovery Acceptance passed

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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

External source cloudbase.it Cloudbase Coriolis Open external site Leads off the trail Code & registry github.com Spring Boot and PostgreSQL example workload Open the repository
SAFE

Prepare Access and Permissions

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.

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.

VMware source1. Disk transfer2. VM deploymentRunning VMApplication + databaseSource VMDKsInitial copy + delta ExecutionsSynchronized STACKIT volumesCloned deployment volumesAdapt guest OS / VirtIO / bootNew STACKIT VMMapped CPU + RAM, NICs, firmware Read source disksDeployment request

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.

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.

  1. Deploy and license a Coriolis appliance with the VMware and STACKIT providers. Use the Coriolis STACKIT Installer 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.

OperatorVMware environmentCoriolis applianceSTACKIT destination projectvCenter / ESXiSource VM: OS, application, databaseREST API and schedulerCoriolis workerTemporary migration workerTransferred volumesMigrated application VM HTTPS: endpoints, transfers, deploymentsSchedule tasksAPI 443 and disk export 902Snapshots and disk accessSSH 22 and HTTPS transfer 5566Write transfer disksClone, morph and deploy

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.

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.

Terminal window
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:

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.

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:

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 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:

Terminal window
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.

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.

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.

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

Terminal window
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:

Terminal window
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:

Terminal window
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.

Terminal window
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 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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

Terminal window
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")

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.

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

Terminal window
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.

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

Section titled “Map source configuration to STACKIT resources”

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

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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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

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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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.

Before the maintenance windowMaintenance windowProduction and retention1. Initial disk TransferSource VM stays on2. New rehearsal VMCloned disks + OS morphing3. Delta ExecutionsValidate with fresh test VM4. Stop writers + source VMRecord final data boundary5. Final disk synchronizationNo application VM yet6. Create final new VMValidate OS + application + data7. Switch approved trafficEnable target writers8. Monitor and hand overKeep source off for recovery Acceptance passed

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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

External source cloudbase.it Cloudbase Coriolis Open external site Leads off the trail Code & registry github.com Spring Boot and PostgreSQL example workload Open the repository
BASE

Connect the Migration Networks

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.

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.

VMware source1. Disk transfer2. VM deploymentRunning VMApplication + databaseSource VMDKsInitial copy + delta ExecutionsSynchronized STACKIT volumesCloned deployment volumesAdapt guest OS / VirtIO / bootNew STACKIT VMMapped CPU + RAM, NICs, firmware Read source disksDeployment request

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.

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.

  1. Deploy and license a Coriolis appliance with the VMware and STACKIT providers. Use the Coriolis STACKIT Installer 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.

OperatorVMware environmentCoriolis applianceSTACKIT destination projectvCenter / ESXiSource VM: OS, application, databaseREST API and schedulerCoriolis workerTemporary migration workerTransferred volumesMigrated application VM HTTPS: endpoints, transfers, deploymentsSchedule tasksAPI 443 and disk export 902Snapshots and disk accessSSH 22 and HTTPS transfer 5566Write transfer disksClone, morph and deploy

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.

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.

Terminal window
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:

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.

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:

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 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:

Terminal window
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.

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.

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.

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

Terminal window
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:

Terminal window
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:

Terminal window
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.

Terminal window
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 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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

Terminal window
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")

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.

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

Terminal window
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.

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

Section titled “Map source configuration to STACKIT resources”

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

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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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

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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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.

Before the maintenance windowMaintenance windowProduction and retention1. Initial disk TransferSource VM stays on2. New rehearsal VMCloned disks + OS morphing3. Delta ExecutionsValidate with fresh test VM4. Stop writers + source VMRecord final data boundary5. Final disk synchronizationNo application VM yet6. Create final new VMValidate OS + application + data7. Switch approved trafficEnable target writers8. Monitor and hand overKeep source off for recovery Acceptance passed

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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

External source cloudbase.it Cloudbase Coriolis Open external site Leads off the trail Code & registry github.com Spring Boot and PostgreSQL example workload Open the repository
STEP

Prepare the VM and Destination

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.

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.

VMware source1. Disk transfer2. VM deploymentRunning VMApplication + databaseSource VMDKsInitial copy + delta ExecutionsSynchronized STACKIT volumesCloned deployment volumesAdapt guest OS / VirtIO / bootNew STACKIT VMMapped CPU + RAM, NICs, firmware Read source disksDeployment request

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.

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.

  1. Deploy and license a Coriolis appliance with the VMware and STACKIT providers. Use the Coriolis STACKIT Installer 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.

OperatorVMware environmentCoriolis applianceSTACKIT destination projectvCenter / ESXiSource VM: OS, application, databaseREST API and schedulerCoriolis workerTemporary migration workerTransferred volumesMigrated application VM HTTPS: endpoints, transfers, deploymentsSchedule tasksAPI 443 and disk export 902Snapshots and disk accessSSH 22 and HTTPS transfer 5566Write transfer disksClone, morph and deploy

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.

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.

Terminal window
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:

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.

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:

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 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:

Terminal window
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.

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.

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.

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

Terminal window
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:

Terminal window
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:

Terminal window
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.

Terminal window
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 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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

Terminal window
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")

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.

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

Terminal window
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.

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

Section titled “Map source configuration to STACKIT resources”

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

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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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

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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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.

Before the maintenance windowMaintenance windowProduction and retention1. Initial disk TransferSource VM stays on2. New rehearsal VMCloned disks + OS morphing3. Delta ExecutionsValidate with fresh test VM4. Stop writers + source VMRecord final data boundary5. Final disk synchronizationNo application VM yet6. Create final new VMValidate OS + application + data7. Switch approved trafficEnable target writers8. Monitor and hand overKeep source off for recovery Acceptance passed

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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

External source cloudbase.it Cloudbase Coriolis Open external site Leads off the trail Code & registry github.com Spring Boot and PostgreSQL example workload Open the repository
PLAN

Set the Operator Inputs

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.

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.

VMware source1. Disk transfer2. VM deploymentRunning VMApplication + databaseSource VMDKsInitial copy + delta ExecutionsSynchronized STACKIT volumesCloned deployment volumesAdapt guest OS / VirtIO / bootNew STACKIT VMMapped CPU + RAM, NICs, firmware Read source disksDeployment request

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.

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.

  1. Deploy and license a Coriolis appliance with the VMware and STACKIT providers. Use the Coriolis STACKIT Installer 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.

OperatorVMware environmentCoriolis applianceSTACKIT destination projectvCenter / ESXiSource VM: OS, application, databaseREST API and schedulerCoriolis workerTemporary migration workerTransferred volumesMigrated application VM HTTPS: endpoints, transfers, deploymentsSchedule tasksAPI 443 and disk export 902Snapshots and disk accessSSH 22 and HTTPS transfer 5566Write transfer disksClone, morph and deploy

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.

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.

Terminal window
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:

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.

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:

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 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:

Terminal window
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.

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.

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.

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

Terminal window
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:

Terminal window
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:

Terminal window
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.

Terminal window
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 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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

Terminal window
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")

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.

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

Terminal window
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.

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

Section titled “Map source configuration to STACKIT resources”

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

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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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

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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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.

Before the maintenance windowMaintenance windowProduction and retention1. Initial disk TransferSource VM stays on2. New rehearsal VMCloned disks + OS morphing3. Delta ExecutionsValidate with fresh test VM4. Stop writers + source VMRecord final data boundary5. Final disk synchronizationNo application VM yet6. Create final new VMValidate OS + application + data7. Switch approved trafficEnable target writers8. Monitor and hand overKeep source off for recovery Acceptance passed

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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

External source cloudbase.it Cloudbase Coriolis Open external site Leads off the trail Code & registry github.com Spring Boot and PostgreSQL example workload Open the repository
SAFE

Authenticate to Coriolis

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.

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.

VMware source1. Disk transfer2. VM deploymentRunning VMApplication + databaseSource VMDKsInitial copy + delta ExecutionsSynchronized STACKIT volumesCloned deployment volumesAdapt guest OS / VirtIO / bootNew STACKIT VMMapped CPU + RAM, NICs, firmware Read source disksDeployment request

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.

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.

  1. Deploy and license a Coriolis appliance with the VMware and STACKIT providers. Use the Coriolis STACKIT Installer 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.

OperatorVMware environmentCoriolis applianceSTACKIT destination projectvCenter / ESXiSource VM: OS, application, databaseREST API and schedulerCoriolis workerTemporary migration workerTransferred volumesMigrated application VM HTTPS: endpoints, transfers, deploymentsSchedule tasksAPI 443 and disk export 902Snapshots and disk accessSSH 22 and HTTPS transfer 5566Write transfer disksClone, morph and deploy

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.

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.

Terminal window
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:

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.

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:

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 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:

Terminal window
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.

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.

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.

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

Terminal window
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:

Terminal window
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:

Terminal window
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.

Terminal window
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 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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

Terminal window
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")

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.

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

Terminal window
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.

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

Section titled “Map source configuration to STACKIT resources”

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

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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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

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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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.

Before the maintenance windowMaintenance windowProduction and retention1. Initial disk TransferSource VM stays on2. New rehearsal VMCloned disks + OS morphing3. Delta ExecutionsValidate with fresh test VM4. Stop writers + source VMRecord final data boundary5. Final disk synchronizationNo application VM yet6. Create final new VMValidate OS + application + data7. Switch approved trafficEnable target writers8. Monitor and hand overKeep source off for recovery Acceptance passed

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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

External source cloudbase.it Cloudbase Coriolis Open external site Leads off the trail Code & registry github.com Spring Boot and PostgreSQL example workload Open the repository
PLAN

Select Providers and Workers

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.

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.

VMware source1. Disk transfer2. VM deploymentRunning VMApplication + databaseSource VMDKsInitial copy + delta ExecutionsSynchronized STACKIT volumesCloned deployment volumesAdapt guest OS / VirtIO / bootNew STACKIT VMMapped CPU + RAM, NICs, firmware Read source disksDeployment request

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.

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.

  1. Deploy and license a Coriolis appliance with the VMware and STACKIT providers. Use the Coriolis STACKIT Installer 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.

OperatorVMware environmentCoriolis applianceSTACKIT destination projectvCenter / ESXiSource VM: OS, application, databaseREST API and schedulerCoriolis workerTemporary migration workerTransferred volumesMigrated application VM HTTPS: endpoints, transfers, deploymentsSchedule tasksAPI 443 and disk export 902Snapshots and disk accessSSH 22 and HTTPS transfer 5566Write transfer disksClone, morph and deploy

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.

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.

Terminal window
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:

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.

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:

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 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:

Terminal window
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.

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.

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.

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

Terminal window
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:

Terminal window
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:

Terminal window
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.

Terminal window
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 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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

Terminal window
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")

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.

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

Terminal window
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.

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

Section titled “Map source configuration to STACKIT resources”

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

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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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

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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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.

Before the maintenance windowMaintenance windowProduction and retention1. Initial disk TransferSource VM stays on2. New rehearsal VMCloned disks + OS morphing3. Delta ExecutionsValidate with fresh test VM4. Stop writers + source VMRecord final data boundary5. Final disk synchronizationNo application VM yet6. Create final new VMValidate OS + application + data7. Switch approved trafficEnable target writers8. Monitor and hand overKeep source off for recovery Acceptance passed

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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

External source cloudbase.it Cloudbase Coriolis Open external site Leads off the trail Code & registry github.com Spring Boot and PostgreSQL example workload Open the repository
BASE

Configure Platform Endpoints

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.

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.

VMware source1. Disk transfer2. VM deploymentRunning VMApplication + databaseSource VMDKsInitial copy + delta ExecutionsSynchronized STACKIT volumesCloned deployment volumesAdapt guest OS / VirtIO / bootNew STACKIT VMMapped CPU + RAM, NICs, firmware Read source disksDeployment request

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.

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.

  1. Deploy and license a Coriolis appliance with the VMware and STACKIT providers. Use the Coriolis STACKIT Installer 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.

OperatorVMware environmentCoriolis applianceSTACKIT destination projectvCenter / ESXiSource VM: OS, application, databaseREST API and schedulerCoriolis workerTemporary migration workerTransferred volumesMigrated application VM HTTPS: endpoints, transfers, deploymentsSchedule tasksAPI 443 and disk export 902Snapshots and disk accessSSH 22 and HTTPS transfer 5566Write transfer disksClone, morph and deploy

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.

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.

Terminal window
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:

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.

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:

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 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:

Terminal window
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.

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.

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.

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

Terminal window
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:

Terminal window
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:

Terminal window
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.

Terminal window
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 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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

Terminal window
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")

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.

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

Terminal window
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.

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

Section titled “Map source configuration to STACKIT resources”

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

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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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

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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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.

Before the maintenance windowMaintenance windowProduction and retention1. Initial disk TransferSource VM stays on2. New rehearsal VMCloned disks + OS morphing3. Delta ExecutionsValidate with fresh test VM4. Stop writers + source VMRecord final data boundary5. Final disk synchronizationNo application VM yet6. Create final new VMValidate OS + application + data7. Switch approved trafficEnable target writers8. Monitor and hand overKeep source off for recovery Acceptance passed

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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

External source cloudbase.it Cloudbase Coriolis Open external site Leads off the trail Code & registry github.com Spring Boot and PostgreSQL example workload Open the repository
AUTO

Build and Synchronize Target Disks

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.

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.

VMware source1. Disk transfer2. VM deploymentRunning VMApplication + databaseSource VMDKsInitial copy + delta ExecutionsSynchronized STACKIT volumesCloned deployment volumesAdapt guest OS / VirtIO / bootNew STACKIT VMMapped CPU + RAM, NICs, firmware Read source disksDeployment request

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.

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.

  1. Deploy and license a Coriolis appliance with the VMware and STACKIT providers. Use the Coriolis STACKIT Installer 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.

OperatorVMware environmentCoriolis applianceSTACKIT destination projectvCenter / ESXiSource VM: OS, application, databaseREST API and schedulerCoriolis workerTemporary migration workerTransferred volumesMigrated application VM HTTPS: endpoints, transfers, deploymentsSchedule tasksAPI 443 and disk export 902Snapshots and disk accessSSH 22 and HTTPS transfer 5566Write transfer disksClone, morph and deploy

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.

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.

Terminal window
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:

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.

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:

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 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:

Terminal window
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.

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.

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.

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

Terminal window
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:

Terminal window
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:

Terminal window
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.

Terminal window
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 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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

Terminal window
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")

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.

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

Terminal window
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.

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

Section titled “Map source configuration to STACKIT resources”

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

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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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

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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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.

Before the maintenance windowMaintenance windowProduction and retention1. Initial disk TransferSource VM stays on2. New rehearsal VMCloned disks + OS morphing3. Delta ExecutionsValidate with fresh test VM4. Stop writers + source VMRecord final data boundary5. Final disk synchronizationNo application VM yet6. Create final new VMValidate OS + application + data7. Switch approved trafficEnable target writers8. Monitor and hand overKeep source off for recovery Acceptance passed

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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

External source cloudbase.it Cloudbase Coriolis Open external site Leads off the trail Code & registry github.com Spring Boot and PostgreSQL example workload Open the repository
LIVE

Create a New STACKIT VM

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.

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.

VMware source1. Disk transfer2. VM deploymentRunning VMApplication + databaseSource VMDKsInitial copy + delta ExecutionsSynchronized STACKIT volumesCloned deployment volumesAdapt guest OS / VirtIO / bootNew STACKIT VMMapped CPU + RAM, NICs, firmware Read source disksDeployment request

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.

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.

  1. Deploy and license a Coriolis appliance with the VMware and STACKIT providers. Use the Coriolis STACKIT Installer 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.

OperatorVMware environmentCoriolis applianceSTACKIT destination projectvCenter / ESXiSource VM: OS, application, databaseREST API and schedulerCoriolis workerTemporary migration workerTransferred volumesMigrated application VM HTTPS: endpoints, transfers, deploymentsSchedule tasksAPI 443 and disk export 902Snapshots and disk accessSSH 22 and HTTPS transfer 5566Write transfer disksClone, morph and deploy

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.

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.

Terminal window
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:

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.

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:

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 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:

Terminal window
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.

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.

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.

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

Terminal window
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:

Terminal window
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:

Terminal window
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.

Terminal window
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 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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

Terminal window
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")

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.

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

Terminal window
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.

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

Section titled “Map source configuration to STACKIT resources”

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

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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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

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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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.

Before the maintenance windowMaintenance windowProduction and retention1. Initial disk TransferSource VM stays on2. New rehearsal VMCloned disks + OS morphing3. Delta ExecutionsValidate with fresh test VM4. Stop writers + source VMRecord final data boundary5. Final disk synchronizationNo application VM yet6. Create final new VMValidate OS + application + data7. Switch approved trafficEnable target writers8. Monitor and hand overKeep source off for recovery Acceptance passed

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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

External source cloudbase.it Cloudbase Coriolis Open external site Leads off the trail Code & registry github.com Spring Boot and PostgreSQL example workload Open the repository
SAFE

Validate the Rehearsal VM

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.

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.

VMware source1. Disk transfer2. VM deploymentRunning VMApplication + databaseSource VMDKsInitial copy + delta ExecutionsSynchronized STACKIT volumesCloned deployment volumesAdapt guest OS / VirtIO / bootNew STACKIT VMMapped CPU + RAM, NICs, firmware Read source disksDeployment request

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.

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.

  1. Deploy and license a Coriolis appliance with the VMware and STACKIT providers. Use the Coriolis STACKIT Installer 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.

OperatorVMware environmentCoriolis applianceSTACKIT destination projectvCenter / ESXiSource VM: OS, application, databaseREST API and schedulerCoriolis workerTemporary migration workerTransferred volumesMigrated application VM HTTPS: endpoints, transfers, deploymentsSchedule tasksAPI 443 and disk export 902Snapshots and disk accessSSH 22 and HTTPS transfer 5566Write transfer disksClone, morph and deploy

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.

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.

Terminal window
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:

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.

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:

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 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:

Terminal window
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.

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.

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.

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

Terminal window
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:

Terminal window
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:

Terminal window
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.

Terminal window
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 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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

Terminal window
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")

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.

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

Terminal window
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.

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

Section titled “Map source configuration to STACKIT resources”

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

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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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

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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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.

Before the maintenance windowMaintenance windowProduction and retention1. Initial disk TransferSource VM stays on2. New rehearsal VMCloned disks + OS morphing3. Delta ExecutionsValidate with fresh test VM4. Stop writers + source VMRecord final data boundary5. Final disk synchronizationNo application VM yet6. Create final new VMValidate OS + application + data7. Switch approved trafficEnable target writers8. Monitor and hand overKeep source off for recovery Acceptance passed

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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

External source cloudbase.it Cloudbase Coriolis Open external site Leads off the trail Code & registry github.com Spring Boot and PostgreSQL example workload Open the repository
AUTO

Synchronize Source Changes

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.

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.

VMware source1. Disk transfer2. VM deploymentRunning VMApplication + databaseSource VMDKsInitial copy + delta ExecutionsSynchronized STACKIT volumesCloned deployment volumesAdapt guest OS / VirtIO / bootNew STACKIT VMMapped CPU + RAM, NICs, firmware Read source disksDeployment request

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.

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.

  1. Deploy and license a Coriolis appliance with the VMware and STACKIT providers. Use the Coriolis STACKIT Installer 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.

OperatorVMware environmentCoriolis applianceSTACKIT destination projectvCenter / ESXiSource VM: OS, application, databaseREST API and schedulerCoriolis workerTemporary migration workerTransferred volumesMigrated application VM HTTPS: endpoints, transfers, deploymentsSchedule tasksAPI 443 and disk export 902Snapshots and disk accessSSH 22 and HTTPS transfer 5566Write transfer disksClone, morph and deploy

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.

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.

Terminal window
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:

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.

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:

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 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:

Terminal window
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.

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.

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.

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

Terminal window
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:

Terminal window
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:

Terminal window
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.

Terminal window
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 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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

Terminal window
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")

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.

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

Terminal window
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.

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

Section titled “Map source configuration to STACKIT resources”

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

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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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

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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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.

Before the maintenance windowMaintenance windowProduction and retention1. Initial disk TransferSource VM stays on2. New rehearsal VMCloned disks + OS morphing3. Delta ExecutionsValidate with fresh test VM4. Stop writers + source VMRecord final data boundary5. Final disk synchronizationNo application VM yet6. Create final new VMValidate OS + application + data7. Switch approved trafficEnable target writers8. Monitor and hand overKeep source off for recovery Acceptance passed

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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

External source cloudbase.it Cloudbase Coriolis Open external site Leads off the trail Code & registry github.com Spring Boot and PostgreSQL example workload Open the repository
LIVE

Freeze, Synchronize and Cut Over

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.

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.

VMware source1. Disk transfer2. VM deploymentRunning VMApplication + databaseSource VMDKsInitial copy + delta ExecutionsSynchronized STACKIT volumesCloned deployment volumesAdapt guest OS / VirtIO / bootNew STACKIT VMMapped CPU + RAM, NICs, firmware Read source disksDeployment request

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.

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.

  1. Deploy and license a Coriolis appliance with the VMware and STACKIT providers. Use the Coriolis STACKIT Installer 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.

OperatorVMware environmentCoriolis applianceSTACKIT destination projectvCenter / ESXiSource VM: OS, application, databaseREST API and schedulerCoriolis workerTemporary migration workerTransferred volumesMigrated application VM HTTPS: endpoints, transfers, deploymentsSchedule tasksAPI 443 and disk export 902Snapshots and disk accessSSH 22 and HTTPS transfer 5566Write transfer disksClone, morph and deploy

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.

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.

Terminal window
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:

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.

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:

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 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:

Terminal window
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.

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.

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.

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

Terminal window
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:

Terminal window
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:

Terminal window
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.

Terminal window
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 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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

Terminal window
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")

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.

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

Terminal window
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.

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

Section titled “Map source configuration to STACKIT resources”

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

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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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

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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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.

Before the maintenance windowMaintenance windowProduction and retention1. Initial disk TransferSource VM stays on2. New rehearsal VMCloned disks + OS morphing3. Delta ExecutionsValidate with fresh test VM4. Stop writers + source VMRecord final data boundary5. Final disk synchronizationNo application VM yet6. Create final new VMValidate OS + application + data7. Switch approved trafficEnable target writers8. Monitor and hand overKeep source off for recovery Acceptance passed

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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

External source cloudbase.it Cloudbase Coriolis Open external site Leads off the trail Code & registry github.com Spring Boot and PostgreSQL example workload Open the repository
GOAL

Hand Over the Migrated Workload

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.

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.

VMware source1. Disk transfer2. VM deploymentRunning VMApplication + databaseSource VMDKsInitial copy + delta ExecutionsSynchronized STACKIT volumesCloned deployment volumesAdapt guest OS / VirtIO / bootNew STACKIT VMMapped CPU + RAM, NICs, firmware Read source disksDeployment request

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.

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.

  1. Deploy and license a Coriolis appliance with the VMware and STACKIT providers. Use the Coriolis STACKIT Installer 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.

OperatorVMware environmentCoriolis applianceSTACKIT destination projectvCenter / ESXiSource VM: OS, application, databaseREST API and schedulerCoriolis workerTemporary migration workerTransferred volumesMigrated application VM HTTPS: endpoints, transfers, deploymentsSchedule tasksAPI 443 and disk export 902Snapshots and disk accessSSH 22 and HTTPS transfer 5566Write transfer disksClone, morph and deploy

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.

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.

Terminal window
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:

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.

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:

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 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:

Terminal window
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.

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.

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.

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

Terminal window
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:

Terminal window
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:

Terminal window
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.

Terminal window
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 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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

Terminal window
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")

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.

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

Terminal window
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.

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

Section titled “Map source configuration to STACKIT resources”

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

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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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

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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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.

Before the maintenance windowMaintenance windowProduction and retention1. Initial disk TransferSource VM stays on2. New rehearsal VMCloned disks + OS morphing3. Delta ExecutionsValidate with fresh test VM4. Stop writers + source VMRecord final data boundary5. Final disk synchronizationNo application VM yet6. Create final new VMValidate OS + application + data7. Switch approved trafficEnable target writers8. Monitor and hand overKeep source off for recovery Acceptance passed

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:

Terminal window
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:

Terminal window
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:

Terminal window
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.

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.

External source cloudbase.it Cloudbase Coriolis Open external site Leads off the trail Code & registry github.com Spring Boot and PostgreSQL example workload Open the repository
Trail historyAdded Oct 4, 2026LWUpdatedNo updates · 1 bar = 1 week i
Maintainers
LWLukas WeberrußHead of STACKIT Cloud Migration Framework · STACKITOwnerActive 10 of the last 12 weeks · 47 updatesSTACKITwww.linkedin.com/in/lukas-weberruß-a360b081Contributed in STACKIT