Coolify server-to-server migration

coolify-migrate

Copy a Coolify instance, or just the apps, databases and services you pick, to another server while the old one keeps running. Test the copy, then cut over when you're ready. If anything fails along the way, both servers are rolled back automatically.

 ██████╗ ██████╗  ██████╗ ██╗     ██╗███████╗██╗   ██╗
██╔════╝██╔═══██╗██╔═══██╗██║     ██║██╔════╝╚██╗ ██╔╝
██║     ██║   ██║██║   ██║██║     ██║█████╗   ╚████╔╝
██║     ██║   ██║██║   ██║██║     ██║██╔══╝    ╚██╔╝
╚██████╗╚██████╔╝╚██████╔╝███████╗██║██║        ██║
 ╚═════╝ ╚═════╝  ╚═════╝ ╚══════╝╚═╝╚═╝        ╚═╝
███╗   ███╗██╗ ██████╗ ██████╗  █████╗ ████████╗███████╗
████╗ ████║██║██╔════╝ ██╔══██╗██╔══██╗╚══██╔══╝██╔════╝
██╔████╔██║██║██║  ███╗██████╔╝███████║   ██║   █████╗
██║╚██╔╝██║██║██║   ██║██╔══██╗██╔══██║   ██║   ██╔══╝
██║ ╚═╝ ██║██║╚██████╔╝██║  ██║██║  ██║   ██║   ███████╗
╚═╝     ╚═╝╚═╝ ╚═════╝ ╚═╝  ╚═╝╚═╝  ╚═╝   ╚═╝   ╚══════╝

Contents

Highlights

Everything in this section ships in the current release.

Area What it does
Minimal downtime Copies while the old server stays online. A final incremental sync moves only the changes made since the first copy.
Two-phase migration Separates copy and test from cutover. You decide when production traffic moves.
Whole or selective moves Clones the complete Coolify instance, or moves only the apps, databases and services you choose.
Many servers into one Detects workloads on localhost and every server managed by the old Coolify, then can consolidate all of them onto the new server.
Empty or active destination Installs Coolify on an empty server, or safely adds resources to a server that already runs Coolify. Existing resources are never overwritten.
Successful release preservation On matching CPU architectures, transfers and starts the exact image that last ran successfully. Across architectures, rebuilds the last successful commit for the new CPU instead of building the latest branch by accident.
Live, consistent data copy Performs a resumable warm transfer, then briefly freezes each database container for a consistent pass. A strict timeout always unfreezes the database.
Git configuration migration Preserves repositories, branches, Git sources, provider credentials and deploy keys.
Secret-safe import Keeps the original APP_KEY for a full clone. When importing into an existing Coolify, decrypts secrets on the old server and re-encrypts them with the destination key.
Full platform state A full clone includes users, teams, settings, SSH keys, proxy configuration, TLS certificates, projects, environments and resource data.
Duplicate-job protection Pauses Coolify schedules and backups on the new server until cutover, then restores them.
Automatic recovery Journals every change, rolls back partial failures, provides --undo, and creates a source-server rollback script for cutover.
DNS transition bridge Can forward visitors who still reach the old IP to the new server until DNS propagation finishes.
Fast transfers Uses resumable rsync, zstd compression, a private-network route when supplied and bounded parallel jobs.
Safety checks Checks CPU architecture, disk space, inodes, Coolify versions, SSH access and host fingerprints before copying.
Hard capacity gate Refuses to start unless the destination has Coolify's minimum 2 CPUs/2 GB RAM, enough disk for the calculated incoming data plus a safety reserve, and enough free inodes.
Live activity Shows an animated status, elapsed time and the latest useful output for discovery, connections, imports, builds and every other potentially slow operation.
Per-project destination mapping When importing into an existing Coolify, asks where each selected source project should go. Different source projects can target different destination projects while preserving environment names.
Flexible SSH Supports keys, SSH agents, passwords, sudo users, custom ports, pinned host keys and temporary restricted setup keys.
One-line start `curl -fsSL https://coolify-migrate.grtsnx.com
Background operation Detaches long migrations safely and lets you reconnect with --attach.
Automation support Offers non-interactive flags, saved migration plans, --plan-only, configurable job limits and checksum-pinned installer execution.
Hardened input handling Validates remote values, avoids eval and source, uses bound SQL parameters, protects rsync arguments and cleans up temporary access on every exit.

Before you start

In this guide, the old server is the server that runs your current Coolify. The new server is the machine you are moving to.

You need:

  • Access to Coolify on the old server.
  • A new server's public IP address. For a full migration, start with an empty Ubuntu 22.04/24.04 or Debian 12 server. Do not install Coolify on it.
  • Access to the new server through your hosting provider's web console. You will paste one command there.
  • Access to your DNS provider, such as Cloudflare, Namecheap or Route 53.
  • Enough free disk space on the new server for the old server's data.

If the old Coolify manages additional servers, the new server needs enough disk, memory and CPU for their combined workloads. Each managed server must be reachable from the old Coolify with its saved SSH key and passwordless sudo, and must be able to reach the new server's SSH port directly.

In the new server's cloud firewall or security group, allow:

  • TCP port 22 from the old server's IP, for the transfer.
  • TCP ports 80 and 443 from the internet, for your sites.
  • TCP port 8000 from your own IP if you want to open the new Coolify dashboard before changing DNS.

Take a fresh backup or server snapshot before you begin. Keep the old server until the new one has worked correctly for several days.

Step-by-step migration

You do not need to know Linux. Follow these steps in order.

1. Open the old server's terminal

Sign in to your current Coolify dashboard. Open:

Servers → localhost → Terminal

If your Coolify version has no web terminal, connect to the old server with SSH instead.

2. Download and start the tool

Paste this one-line command into that terminal and press Enter:

curl -fsSL https://coolify-migrate.grtsnx.com | bash

The tool saves itself as ~/coolify-migrate.sh, then asks questions before it changes either server. A checksum-verified alternative is in .

3. Answer the prompts

Use these answers for a normal full migration:

Prompt What to choose or enter
Migrate from this server? Choose Yes.
New server Enter root@NEW_SERVER_IP. Replace NEW_SERVER_IP with the address from your hosting provider. If root login is blocked, use ubuntu@..., admin@... or the username your provider gives you.
Trust this server? Check that the displayed fingerprint matches the fingerprint in your provider's console, then choose Yes.
How should I log in? Choose Create a new key for me. The temporary public key defaults to 24 hours; enter a different lifetime when asked if needed.
Copy this whole line... Copy the green command. Open the new server's web console, paste the command there, run it once, then return to the old server and press Enter.
What do you want to copy? Choose Everything for a complete move. Choose Pick apps / databases / services only if you want part of the old Coolify.
What should happen to managed-server workloads? Choose Move them onto the new server to combine localhost and all additional servers into one. Choose Keep them only if those machines will continue running. This prompt appears only when additional servers are detected.
Options Keep the defaults selected and press Enter.
Start copying? Review the OLD and NEW addresses, then choose Yes.

The first phase only creates a copy. Your old server continues to serve your sites.

4. Let the copy finish

You may close the browser tab after the background worker starts. To watch it again, open the old server's terminal and run:

bash ~/coolify-migrate.sh --attach

Do not continue until the tool prints Copy complete. If it reports an error, it automatically removes its partial changes from the new server.

5. Test the new server

Open http://NEW_SERVER_IP:8000 in your browser and sign in with your normal Coolify email and password.

Check each important app and database. To test a domain without changing public DNS, temporarily point that domain to the new IP in your computer's hosts file. USAGE.md gives instructions for Windows, macOS and Linux.

The tool pauses Coolify schedules and backups on the new server during testing. Jobs built into your own app may still run twice, so keep this testing period short.

If the copy is wrong or you no longer want it, run this on the old server:

bash ~/coolify-migrate.sh --undo

6. Switch to the new server

Choose a quiet time. In the old server's terminal, run:

bash ~/coolify-migrate.sh --cutover

Confirm the OLD and NEW addresses again. The tool stops the migrated resources on the old server, copies the final changes, and starts them on the new server. This final sync is the only period when your apps may be unavailable.

For a full migration, the web terminal will disconnect when the old Coolify stops. This is expected; the migration continues in the background. Connect to the old server with SSH and run bash ~/coolify-migrate.sh --attach to watch it.

Do not update DNS until the tool prints Cutover complete.

7. Change DNS

The completion report lists every hostname to change and the new IP. At your DNS provider:

  1. Find each listed A record.
  2. Replace the old server's IP with the new server's IP.
  3. Save the record. Leave its other settings unchanged.

DNS changes may take time to reach every visitor. For a full migration, the tool can forward traffic that still reaches the old IP to the new server during this period.

8. Finish safely

Open your sites from a phone and a computer, submit a real request, and check recent database data. Keep the old server for several days. Delete it only after every site, scheduled task, backup and database works on the new server.

If you must return to the old server after cutover, run the rollback command printed by the tool:

bash /var/lib/coolify-migrate/rollback.sh

For hosts-file instructions and common failures, read the full walkthrough.

Command-line example

Operators can run the same flow without the wizard. This example runs on the old server and uses an SSH key already stored there:

bash ~/coolify-migrate.sh \
  --src local \
  --dst root@203.0.113.20 \
  --dst-key /root/.ssh/id_ed25519 \
  --dst-host-key SHA256:REPLACE_WITH_SERVER_FINGERPRINT \
  --all \
  --no-detach

bash ~/coolify-migrate.sh --cutover

Run bash ~/coolify-migrate.sh --help for every flag. Test with --plan-only before unattended use.

To consolidate localhost plus all servers managed by the old Coolify onto one empty new server, add --consolidate:

bash ~/coolify-migrate.sh --src local --dst root@203.0.113.20 \
  --dst-key /root/.ssh/id_ed25519 \
  --dst-host-key SHA256:REPLACE_WITH_SERVER_FINGERPRINT \
  --all --consolidate --no-detach

How it works

Phase What happens Old server
Copy Coolify is installed on the new server (if it doesn't have it yet), then data is copied in two passes. The first is a warm, resumable copy. The second is a consistent pass in which each database container is frozen for at most 30 seconds (docker pause), never stopped. Managed servers send their data directly to the destination over pinned SSH; the controller does not become a data bottleneck. If the limit is reached, the database is immediately unfrozen and the copy aborts safely. Running. Nothing is stopped.
Test Open the new dashboard on its IP, or point a hostname at the new IP in your hosts file. Scheduled tasks and backups stay paused on the new server, so nothing runs twice. Serving all traffic
Cutover (--cutover) Stops the migrated resources on the old server and syncs only what changed since the copy, including the latest database rows. Then it starts everything on the new server and turns scheduled tasks back on. For a full migration it can also forward the old IP's traffic to the new server until DNS switches; the forward removes itself once DNS has changed. Stopped by you, recorded in rollback.sh
DNS Point the records it lists at the new IP. Delete it once you're happy

What you can migrate

New server is empty New server already runs Coolify
Everything Full clone: users, teams, settings, secrets (APP_KEY), SSH keys, proxy config and TLS certificates, every app, database and service with its data. You log in with the same account. Every resource is added to the existing Coolify. Its own users, projects and settings aren't changed.
Selected apps / databases / services A fresh Coolify is installed and your admin account is copied over (same email and password), then your selection is imported. Your selection is added to the existing Coolify.

For an empty destination, Everything can also consolidate a multi-server installation. The tool inventories each managed server, copies its volumes, bind mounts and last successful images, maps every workload to the new localhost, and removes retired server records from the new dashboard after a successful cutover. The original dashboard and source servers remain available for rollback.

When copying into a Coolify that already exists, secrets are decrypted on the old server and re-encrypted with the new server's own APP_KEY, all through Coolify's own models. Projects and environments are matched by name, or created if missing. Git sources, provider credentials and deploy keys come along; each app remains connected to the same repository and branch. If the dashboard hostname changes, verify the Git provider's webhook or app callback after migration. A resource that already exists on the new server (same UUID) is skipped, never overwritten.

Builds and CPU architecture

The migrator starts the exact release that was running successfully on the old server:

  • On the same CPU architecture, it transfers that built image and asks Coolify to restart from it. Coolify builds only if the expected image is missing.
  • When CPU architectures differ, such as x86_64 to ARM64, the old image cannot run on the new CPU. The migrator pins the last successful commit and builds a native image on the new server while the old server remains live. It never silently builds a newer branch HEAD when a successful commit is known.

Automatic rollback

Every change on the new server is written to a journal (/var/lib/coolify-migrate/journal). If a run fails, or you stop it with kill, the tool rolls back without being asked:

  • The new server was empty: it's wiped back to empty. That removes containers, volumes, /data/coolify, the keys we added, and Docker itself if the migration installed it.
  • The new server already ran Coolify: exactly the rows, containers, volumes and folders that were added are removed. Nothing else is touched.
  • The old server during the copy phase: nothing was stopped there, so nothing needs undoing.
  • The old server during cutover: everything is restarted from rollback.sh with the original restart policies. The copy on the new server is kept, so you can just run --cutover again.

Use --no-auto-rollback to keep a failed copy for inspection. --undo performs the same journaled removal on demand.

Options

Phases       (default) copy  |  --cutover  |  --undo  |  --cleanup-orphans  |  --attach
Servers      --src local | user@host[:port]     --dst user@host[:port]
             --src-key/--dst-key PATH   --src-password/--dst-password   --src-agent/--dst-agent
             --src-host-key/--dst-host-key SHA256:...   --accept-new-host-key
             --dst-reach HOST[:PORT]    new server's address as seen from the old one (private network = faster)
             sudo password if needed: prompted, or $SRC_SUDO_PASSWORD / $DST_SUDO_PASSWORD
What         --all | --resources UUID,UUID
             --target-project UUID  place selected resources in an existing destination project
             --consolidate       move localhost + all managed servers onto one empty destination
Behaviour    --no-volumes --no-binds --no-images --no-start --no-pause-tasks --no-ip-rewrite
             --no-compress --no-bridge --no-auto-rollback --relay --remote-fix
             --jobs N --freeze-timeout SECONDS --installer-sha256 HEX
             --key-ttl-hours HOURS (default 24; range 1-720)
             --coolify-version X   --detach/--no-detach   --plan-only   -y/--yes

The update check makes one request to coolify-migrate.grtsnx.com at startup. Turn it off with COOLIFY_MIGRATE_NO_UPDATE_CHECK=1.

Security

The tool was audited line by line, and every finding is fixed in v2. The main protections:

  • Everything a server reports back is treated as untrusted. Values are validated against strict patterns before they reach bash arithmetic, a shell, SQL, rsync or your terminal. Remote output is emitted on single lines, and labels are base64-encoded. SQL uses bound parameters (psql -v). Remote paths are quoted, and rsync runs with --protect-args.
  • Saved migration plans are data, not shell scripts. Versioned, base64-encoded fields pass an allowlist and validation before use. Legacy plans use a restricted parser; neither format is executed with source or eval.
  • Unsafe paths are refused. Bind mounts are only copied from data locations; system paths such as /etc, /root, /var/lib and /home/ are never copied. Mirroring with --delete is only used on volumes and /data/coolify. Empty or root destinations are refused outright.
  • Keys:
    • The server-to-server transfer key is created for each run, marked restrict and expires after 24 hours by default. Change it with --key-ttl-hours HOURS. The new server's host key is pinned, fetched over the already-authenticated connection, so trust is never blind.
    • On first contact with a server, its fingerprint is shown and you're asked to confirm it.
    • Unattended runs refuse unknown host keys unless you pin --src-host-key / --dst-host-key, or explicitly choose --accept-new-host-key.
    • The setup key you paste on the new server uses the same 24-hour default and is removed when the run ends.
  • Temporary access is removed on every exit, including failures, Ctrl-C and kill. That covers the transfer key, a temporary sudoers drop-in (checked with visudo), the password-login sshd setting, database dumps and export files.
    • A sudoers file left behind by an earlier run that was killed is detected and removed.
    • A pasted private key is deleted after cutover.
  • Secrets stay private.
    • Passwords are never written to disk or logs, or put on a command line.
    • Dumps and exports live in /var/lib/coolify-migrate, owned by root with mode 700.
    • Files handed to Coolify's PHP are owned by its user with mode 600.
    • Logs are mode 600.
  • No third-party images. The traffic bridge reuses the Traefik image your Coolify already runs. Its self-removal runs from cron, so no container is given docker.sock.
  • Managed-server checks keep a dedicated known-hosts file. Automatic firewall changes are off by default. --remote-fix only adds an allow rule for the new server's IP on the SSH port, tagged coolify-migrate.
  • Installer pinning is available. In controlled environments, pass the expected official installer digest with --installer-sha256 HEX or COOLIFY_INSTALL_SHA256; a mismatch stops the run before execution.

Verify the script before running it as root:

curl -fsSL https://coolify-migrate.grtsnx.com -o coolify-migrate.sh
curl -fsSL https://gist.githubusercontent.com/grtsnx/e73980ff9ecc011e7ea843863ebe1cac/raw/coolify-migrate.sh.sha256 -o coolify-migrate.sh.sha256
sha256sum -c coolify-migrate.sh.sha256

Requirements and limits

  • Users: root, or a user with sudo. Password sudo works too; you're asked once.
  • New server: a Coolify-supported OS. rsync, zstd and curl are installed automatically.
  • Coolify versions: both servers need Coolify v4. When copying into an existing Coolify, use the same or a newer version than the old server. Columns the new server doesn't have are skipped.
  • CPU architecture: matching architectures reuse copied successful images. If architectures differ (for example x86_64 → ARM), a native rebuild is unavoidable; it uses the last successful commit while the old server remains live. Database volumes are copied as files; check your databases after cutover.
  • Multi-server consolidation: supported for --all onto an empty destination. Every managed source needs working SSH through the key already saved in Coolify, passwordless sudo for non-root users, and direct SSH access to the destination. --relay is intentionally unavailable for consolidation because it cannot provide the same bounded live-database consistency. Identical volume names or overlapping bind paths on different source hosts are rejected before copying so their data can never be merged accidentally.
  • Copying into an existing Coolify:
    • Resources land in its root team.
    • Remote servers managed by the old Coolify aren't imported.
    • S3 backup destinations need to be re-selected.
  • Traffic bridge: while active, the new server sees bridged visitors as coming from the old server's IP. It's only offered for full migrations.

Roadmap

These items are planned or being explored; they are not part of the current release. Priorities may change as real migrations expose better opportunities.

Planned next

  • Build readiness gate: wait for destination builds and health checks, then show one clear success or failure report before cutover.
  • Controlled cross-architecture build queue: limit concurrent native builds by available CPU and memory so a large migration cannot overload the new server.
  • Multi-architecture registry reuse: detect a matching platform image in the registry and pull it instead of rebuilding when one exists.
  • Pre-cutover drift report: show repository, configuration and data changes made after the copy phase.
  • Machine-readable reports: produce optional JSON summaries for automation, auditing and support.
  • Signed releases: add cryptographic release signatures or attestations alongside the existing SHA-256 verification.

Exploring

  • DNS provider integrations: optionally switch and roll back supported DNS records after explicit confirmation.
  • Encrypted migration bundles: export to encrypted storage when the old and new servers cannot connect directly.
  • Completion notifications: send success, failure and action-required notices through email, Slack or Discord.
  • Managed registry and build-cache handoff: carry reusable cache layers between servers to reduce unavoidable build time.
  • Optional web interface: provide a visual migration view without replacing the script or command-line workflow.

Ideas and real-world migration reports are welcome in the public gist comments.

Tested

Every flow below was tested end to end on real Coolify 4.3.18: the official installer, the real database schema, real deployments and real data.

Test Result
Full clone, copy phase, running from the old server Same APP_KEY, data checksum identical, old server never stopped
Rows written after the copy, then --cutover All 5,100 rows on the new server, checksum identical
Cutover failure Old server restarted automatically from rollback.sh within 3 s
Selected app + database into an existing Coolify (different APP_KEY) Secrets re-encrypted and readable, existing admin and projects untouched, data identical
--undo on that Coolify Exactly the imported rows, containers, volumes and folders removed

The current script was also revalidated on real Coolify 4.3.23 using two isolated Ubuntu 24.04 hosts. The full-copy test preserved APP_KEY, a PostgreSQL marker table, a managed Docker volume and its image; post-copy database and volume changes arrived during --cutover; and the generated rollback script restored the source containers with their original restart policy. This run also found and fixed an empty localhost SSH user after clone restore, which had prevented the destination proxy from starting.

The v2.3.0 many-to-one path is covered by the regression suite: managed-server SQL discovery (including Swarm apps), plan persistence, local/remote storage collision rejection, destination remapping, resumable direct rsync, bounded database freezes, temporary-key cleanup and UUID-based cutover rollback. It has not yet been exercised in a production three-host migration, so keep snapshots and verify the copy before cutover as described above.

Testing found 12 bugs that a mocked test would have missed. One matters for anyone running a migration tool next to Coolify: Coolify's installer deletes every authorized_keys line containing "coolify". That's why this tool's key comments never include the word.

Run the local regression and syntax checks with:

/bin/bash tests/test.sh
shellcheck -S error coolify-migrate.sh tests/test.sh

Changelog

  • 2.5.0:
    • Adds independent destination selection for every selected source project.
    • Uses source project UUIDs for unambiguous mappings and persists them safely in the migration plan.
    • Fixes the existing --target-project value being lost at the Coolify container boundary.
  • 2.4.1:
    • Fixes --cleanup-orphans so active queue rows with a null application_id are detected and cancelled after an older undo.
    • Limits repair cleanup to orphaned Queued and In progress records; valid applications and completed history remain untouched.
  • 2.4.0:
    • Adds animated elapsed-time activity for slow discovery, connection, import, route, volume and start operations.
    • Makes destination CPU, RAM, disk-space safety reserve and inode checks a hard pre-migration gate.
    • Lets selective imports target an existing destination project while preserving source environment names.
    • Changes temporary setup and transfer public keys to a 24-hour default with --key-ttl-hours configuration.
    • Undo now cancels imported deployment queues, helper containers and recorded build processes before deleting rows.
    • Adds --cleanup-orphans to repair stale queue entries left by older undo versions without touching valid deployments.
  • 2.3.0:
    • Consolidates localhost and all Coolify-managed workload servers onto one empty destination.
    • Discovers standalone and Swarm applications, services, databases, volumes, bind mounts, images, running releases and public domains on their actual source hosts.
    • Transfers managed-server data directly to the destination with resumable rsync and pinned, one-day SSH credentials.
    • Resolves current workload containers at cutover, bounds every database freeze, and automatically restarts every source host on failure.
    • Rejects volume-name and overlapping bind-path collisions before data moves.
    • Bridges traffic from every retired source IP during DNS propagation and removes retired server records after a successful cutover.
  • 2.2.0:
    • One-line installation saves a reusable ~/coolify-migrate.sh for later cutover and undo.
    • Apps are pinned to the running or recorded last successful commit.
    • Same-architecture migrations use Coolify's restart-only path and rebuild only if an image is missing.
    • Cross-architecture migrations clearly report the required native builds instead of claiming image reuse.
    • Image/start choices now survive into the saved cutover plan.
  • 2.1.0:
    • Saved plans are strictly parsed data and can no longer execute shell code.
    • Unattended SSH requires pinned fingerprints or explicit TOFU consent.
    • Managed-server host keys are remembered; firewall mutation is opt-in.
    • Warm transfers resume partial files, negotiate zstd and run with bounded concurrency.
    • Database freezes have a hard 30-second default limit and always unpause on timeout.
    • Optional SHA-256 pinning protects Coolify installer execution.
  • 2.0.0:
    • New default: copy while the old server keeps running, then --cutover.
    • Migrate selected apps, databases or services, onto an empty server or into an existing Coolify.
    • Journaled automatic rollback, plus --undo.
    • Every finding from the security audit fixed.
    • Databases copied consistently while live.
    • Scheduled tasks paused on the new server until cutover.
    • Traefik-based bridge with no third-party images.
    • Fixed the installer deleting the transfer keys.
  • 1.1.0: live progress bars, update check.
  • 1.0.0: first release.

Built by grtsnx. If this saved your weekend, a ⭐ or a follow is appreciated.

#!/usr/bin/env bash
# Support the Coolify-style one-line command while keeping a local copy for
# later --cutover, --undo, and --attach commands.
if [[ -z ${BASH_SOURCE[0]:-} || ! -f ${BASH_SOURCE[0]:-} ]]; then
_cm_url="${COOLIFY_MIGRATE_URL:-https://coolify-migrate.grtsnx.com}"
_cm_dst="${COOLIFY_MIGRATE_SCRIPT:-$HOME/coolify-migrate.sh}"
_cm_tmp="${_cm_dst}.tmp.$"
mkdir -p "$(dirname "$_cm_dst")" || exit 1
curl -fsSL "$_cm_url" -o "$_cm_tmp" || { rm -f "$_cm_tmp"; exit 1; }
/bin/bash -n "$_cm_tmp" || { rm -f "$_cm_tmp"; exit 1; }
chmod 700 "$_cm_tmp" && mv -f "$_cm_tmp" "$_cm_dst" || { rm -f "$_cm_tmp"; exit 1; }
# Let the first curl finish cleanly before replacing this stdin-driven shell.
cat >/dev/null
exec /bin/bash "$_cm_dst" "$@"
fi
# =============================================================================
# coolify-migrate — copy Coolify (or selected apps / databases / services) to
# another server while the old one keeps running, then cut over when ready.
#
# * whole instance onto an empty server (users, settings, secrets, certs, data)
# * selected resources onto an empty server or INTO an existing Coolify
# * databases copied consistently while live (frozen for seconds, not stopped)
# * every change on the new server is journaled: failures roll back automatically
#
# Run it ON the old server (e.g. from Coolify's own web terminal) or from any
# machine with SSH access to both. Works with SSH keys, passwords, ssh-agent,
# root or sudo (with or without password).
#
# curl -fsSL https://coolify-migrate.grtsnx.com | bash
#
# Verified download alternative:
# curl -fsSL https://coolify-migrate.grtsnx.com -o coolify-migrate.sh
# curl -fsSL https://gist.githubusercontent.com/grtsnx/e73980ff9ecc011e7ea843863ebe1cac/raw/coolify-migrate.sh.sha256 -o coolify-migrate.sh.sha256
# sha256sum -c coolify-migrate.sh.sha256 && bash coolify-migrate.sh
# ./coolify-migrate.sh --src root@1.2.3.4 --dst root@5.6.7.8 --dst-key ~/.ssh/id_ed25519
#
# Compatible with bash 3.2+ (macOS default) on the operator side.
# =============================================================================
set -o pipefail
TOOL_VERSION="2.5.0"
TOOL_URL="https://coolify-migrate.grtsnx.com"
SCRIPT_PATH=${BASH_SOURCE[0]}
[[ $SCRIPT_PATH == /* ]] || SCRIPT_PATH="$PWD/$SCRIPT_PATH"
# ---------------------------------------------------------------- defaults ---
STATE_DIR="${COOLIFY_MIGRATE_HOME:-$HOME/.coolify-migrate}"
RUN_ID="$(date +%Y%m%d-%H%M%S)"
LOG="$STATE_DIR/logs/$RUN_ID.log"
RWD="/var/lib/coolify-migrate" # work dir on both servers
INSTALL_URL="${COOLIFY_INSTALL_URL:-https://cdn.coollabs.io/coolify/install.sh}"
INSTALL_SHA256="${COOLIFY_INSTALL_SHA256:-}"
# Key comments must never contain "coolify": Coolify's installer runs sed -i "/coolify/d" ~/.ssh/authorized_keys
KEY_MARKER="cmig-ephemeral"
SETUP_MARKER="cmig-setup"
SUDOERS_FILE="/etc/sudoers.d/zz-coolify-migrate"
PWCONF="/etc/ssh/sshd_config.d/00-coolify-migrate.conf"
SETUP_KEY="$HOME/.ssh/coolify_migrate_ed25519"
LIVE=0 INTERACTIVE=1 ASSUME_YES=0 PLAN_ONLY=0 FORCE_RELAY=0 ACCEPT_NEW_HOST_KEY=0 CONSOLIDATE=0
OPT_VOLUMES=1 OPT_BINDS=1 OPT_IMAGES=1 OPT_START=1 OPT_PAUSE_TASKS=1 OPT_IPREWRITE=1 OPT_COMPRESS=1 OPT_REMOTES=0 OPT_BRIDGE=1
CROSS_ARCH=0
AUTO_ROLLBACK=1 CUTOVER=0 UNDO=0 CLEANUP_ORPHANS=0 WHAT="" ENGINE="" TARGET_KIND="" SEL_UUIDS="" PHASE="" EXECUTING=0 OVERWRITE=0
SRC_LOCAL=0 DETACH=auto ATTACH=0 HANDED_OFF=0 WORKER_RC_FILE=""
SUDO_GRANT_SRC=0 SUDO_GRANT_DST=0 PWGUIDE_SRC=0 PWGUIDE_DST=0
COOLIFY_VERSION_OVERRIDE="" DST_REACH=""
SRC_HOST="" SRC_USER="" SRC_PORT="" SRC_AUTH="" SRC_KEY="" SRC_PASS="" SRC_SUDO=""
DST_HOST="" DST_USER="" DST_PORT="" DST_AUTH="" DST_KEY="" DST_PASS="" DST_SUDO=""
SRC_HOST_KEY="" DST_HOST_KEY="" FREEZE_TIMEOUT=30 TRANSFER_JOBS=2 KEY_TTL_HOURS=${COOLIFY_MIGRATE_KEY_TTL_HOURS:-24}
TARGET_PROJECT_UUID=""
# discovered facts (all validated on the way in)
S_VERSION="" S_ARCH="" S_OS="" S_IP="" S_DATA=0 S_DBU=coolify S_DBN=coolify S_MID=""
S_LOCAL_IP="" S_LOCAL_USER=root S_LOCAL_PUB="" S_COUNTS="" S_DOCKERCFG="" N_REMOTE=0
D_ARCH="" D_OS="" D_IP="" D_FREE=0 D_INODES=0 D_CPU=0 D_MEM_TOTAL=0 D_COOLIFY="" D_USERS=0 D_DATA="" D_MID="" D_HASDOCKER=0 D_JOURNAL=0
VOLS=() BINDS=() SKIPPED_BINDS=() IMGS=() RUNNING=() FQDNS=() DMOUNTS=() RES=()
DPROJECTS=()
PROJECT_MAPS=()
# Workloads discovered on servers managed by the source Coolify. Records keep
# their source server so data is always read from the host that owns it.
MSERVERS=() MRES=() MVOLS=() MBINDS=() MIMGS=() MRUNNING=()
MODE="direct" COMP="gzip -1 -c" DECOMP="gzip -dc" DSSH="" DT="" TRANSFER_REACH="" TRANSFER_PORT=22
# ---------------------------------------------------------------------- UI ---
if [[ -t 1 && -z ${NO_COLOR:-} ]]; then
B= \033[1m' D= \033[2m' R= \033[0m' RED= \033[31m' GRN= \033[32m'
YEL= \033[33m' BLU= \033[34m' MAG= \033[35m' CYN= \033[36m'
else
B="" D="" R="" RED="" GRN="" YEL="" BLU="" MAG="" CYN=""
fi
TTY=/dev/tty
cols() { local c; c=$(tput cols 2>/dev/null) || c=100; [[ $c -gt 20 ]] || c=100; echo "$c"; }
# UTF-8 terminal? (override: COOLIFY_MIGRATE_ASCII=1/0). Non-UTF-8 locales get plain ASCII.
case "${COOLIFY_MIGRATE_ASCII:-}" in
1) UTF8=0 ;; 0) UTF8=1 ;;
*) case "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" in *[Uu][Tt][Ff]-8*|*[Uu][Tt][Ff]8*) UTF8=1 ;; *) UTF8=0 ;; esac ;;
esac
if ((UTF8)); then
G_OK='✔' G_ERR='✖' G_WARN='▲' G_INFO='•' G_HR='─' G_SUB='›' G_PTR='❯' G_ON='●' G_OFF='○'
G_ARR='→' G_DOT='·' G_ELL='…' G_DASH='—' G_NDASH='–' G_APPROX='≈' G_GE='≥' G_UP='↑' G_DOWN='↓' G_PARTY='🎉'
SPIN=(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏) G_BFULL='━' G_BEMPTY='─' G_PFULL='█' G_PEMPTY='░'
else
G_OK='+' G_ERR='x' G_WARN='!' G_INFO='*' G_HR='-' G_SUB='>' G_PTR='>' G_ON='[x]' G_OFF='[ ]'
G_ARR='->' G_DOT='|' G_ELL='...' G_DASH='-' G_NDASH='-' G_APPROX='~' G_GE='>=' G_UP='up' G_DOWN='down' G_PARTY='**'
SPIN=('|' '/' '-' '\' '|' '/' '-' '\' '|' '/') G_BFULL='=' G_BEMPTY='-' G_PFULL='#' G_PEMPTY='.'
fi
# Coolify's installer cracks jokes while you wait. So do we.
JOKES=(
"Moving servers is like moving house, except the boxes are containers and nobody offers to help."
"Your data is flying first class today: zstd-compressed, extra legroom."
"It's not slow, it's thorough."
"rsync has been moving files since 1996. It has seen things."
"Docker volumes don't get homesick. They just get mounted somewhere else."
"It's not DNS. There's no way it's DNS. ... It was DNS."
"The old server is taking the news surprisingly well."
"Packing your containers. Bubble wrap not included."
"Postgres is holding still for pg_dump. Don't worry, it's a professional."
"The two hardest things in computing: cache invalidation, naming things, and off-by-one errors."
"Zero downtime is a mindset. Low downtime is a script."
"No servers were harmed in the making of this migration."
"Grab a coffee. The bytes have it from here."
"Teaching your apps their new address. They're quick learners."
"Somewhere, a sysadmin is doing this by hand at 3am. Not you."
"Copying your secrets. Don't worry, we're not telling anyone."
"Fun fact: 'cloud' is just someone else's computer. Now it's a different someone else's computer."
)
quip() { printf ' %s%s %s%s\n' "$D" "$([[ $UTF8 == 1 ]] && printf '💬' || printf '>')" "${JOKES[RANDOM % ${#JOKES[@]}]}" "$R"; }
log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" >>"$LOG"; }
info() { printf ' %s'"${G_INFO}"'%s %s\n' "$BLU" "$R" "$*"; log "INFO $*"; }
ok() { printf ' %s'"${G_OK}"'%s %s\n' "$GRN" "$R" "$*"; log "OK $*"; }
warn() { printf ' %s'"${G_WARN}"'%s %s\n' "$YEL" "$R" "$*"; log "WARN $*"; }
err() { printf ' %s'"${G_ERR}"'%s %s\n' "$RED" "$R" "$*" >&2; log "ERR $*"; }
die() { err "$*"; printf '\n %sLog:%s %s\n\n' "$D" "$R" "$LOG" >&2; exit 1; }
self_cmd() { printf 'bash %q' "$SCRIPT_PATH"; }
hr() { local i n; n=$(cols); ((n > 90)) && n=90; printf '%s' "$D"; for ((i = 0; i < n; i++)); do printf ''"${G_HR}"''; done; printf '%s\n' "$R"; }
STEP_N=0 STEP_T=0
RUN_START=$SECONDS
# Bar of N cells, P percent filled
bar() { # pct width
local p=$1 w=$2 f i out=""
((p > 100)) && p=100; ((p < 0)) && p=0
f=$((p * w / 100))
for ((i = 0; i < w; i++)); do if ((i < f)); then out+="$G_BFULL"; else out+="$G_BEMPTY"; fi; done
printf '%s' "$out"
}
section() {
STEP_N=$((STEP_N + 1))
((STEP_N > STEP_T)) && STEP_T=$STEP_N
local pct=$(((STEP_N - 1) * 100 / STEP_T))
printf '\n%s%s[%d/%d]%s %s%s%s %s%s%s %s%d%% %s %s%s\n' "$B" "$MAG" "$STEP_N" "$STEP_T" "$R" "$B" "$1" "$R" \
"$CYN" "$(bar "$pct" 16)" "$R" "$D" "$pct" "$G_DOT" "$(fmt_dur $((SECONDS - RUN_START)))" "$R"
log "=== [$STEP_N/$STEP_T] $1"
}
banner() {
local w; w=$(cols)
echo
if ((UTF8 && w >= 58)); then
printf '%s%s' "$B" "$CYN"
cat <<'EOF'
██████╗ ██████╗ ██████╗ ██╗ ██╗███████╗██╗ ██╗
██╔════╝██╔═══██╗██╔═══██╗██║ ██║██╔════╝╚██╗ ██╔╝
██║ ██║ ██║██║ ██║██║ ██║█████╗ ╚████╔╝
██║ ██║ ██║██║ ██║██║ ██║██╔══╝ ╚██╔╝
╚██████╗╚██████╔╝╚██████╔╝███████╗██║██║ ██║
╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝╚═╝ ╚═╝
EOF
printf '%s' "$MAG"
cat <<'EOF'
███╗ ███╗██╗ ██████╗ ██████╗ █████╗ ████████╗███████╗
████╗ ████║██║██╔════╝ ██╔══██╗██╔══██╗╚══██╔══╝██╔════╝
██╔████╔██║██║██║ ███╗██████╔╝███████║ ██║ █████╗
██║╚██╔╝██║██║██║ ██║██╔══██╗██╔══██║ ██║ ██╔══╝
██║ ╚═╝ ██║██║╚██████╔╝██║ ██║██║ ██║ ██║ ███████╗
╚═╝ ╚═╝╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚══════╝
EOF
elif ((UTF8)); then
printf '%s%s' "$B" "$CYN"
cat <<'EOF'
┌─┐┌─┐┌─┐┬ ┬┌─┐┬ ┬ ┌┬┐┬┌─┐┬─┐┌─┐┌┬┐┌─┐
│ │ ││ ││ │├┤ └┬┘───│││││ ┬├┬┘├─┤ │ ├┤
└─┘└─┘└─┘┴─┘┴└ ┴ ┴ ┴┴└─┘┴└─┴ ┴ ┴ └─┘
EOF
else
printf '%s%s' "$B" "$CYN"
cat <<'EOF'
_ _ __ _ _
__ ___ ___| (_)/ _|_ _ ___ _ __ (_)__ _ _ _ __ _| |_ ___
/ _/ _ \/ _ \ | | _| || |___| ' \| / _` | '_/ _` | _/ -_)
\__\___/\___/_|_|_| \_, | |_|_|_|_\__, |_| \__,_|\__\___|
|__/ |___/
EOF
fi
printf '%s\n %sone-click Coolify server migration %s v%s%s\n' "$R" "$D" "$G_DOT" "$TOOL_VERSION" "$R"
printf ' %sbuilt by %s%sgrtsnx%s %s %s%shttps://github.com/grtsnx%s\n\n' "$D" "$R" "$B" "$R" "$G_DOT" "$R" "$CYN" "$R"
}
hsize() { awk -v b="${1:-0}" 'BEGIN{split("B KB MB GB TB",u);i=1;while(b>=1024&&i<5){b/=1024;i++}printf (i==1?"%d %s":"%.1f %s"),b,u[i]}'; }
fmt_dur() { local s=$1; if ((s >= 60)); then printf '%dm%02ds' $((s / 60)) $((s % 60)); else printf '%ds' "$s"; fi; }
# Widget locals are __-prefixed so they never shadow the caller's result variable.
ui_ask() { # var prompt [default]
local __v=$1 __p=$2 __d=${3:-} __a=""
if ((!INTERACTIVE)); then printf -v "$__v" '%s' "$__d"; return; fi
printf ' %s?%s %s%s%s ' "$CYN" "$R" "$B" "$__p" "$R" >"$TTY"
[[ -n $__d ]] && printf '%s(%s)%s ' "$D" "$__d" "$R" >"$TTY"
IFS= read -r __a <"$TTY"
printf -v "$__v" '%s' "${__a:-$__d}"
}
ui_secret() { # var prompt
local __v=$1 __a=""
printf ' %s?%s %s%s%s ' "$CYN" "$R" "$B" "$2" "$R" >"$TTY"
IFS= read -rs __a <"$TTY"; printf '%s'"${G_INFO}"''"${G_INFO}"''"${G_INFO}"''"${G_INFO}"''"${G_INFO}"''"${G_INFO}"'%s\n' "$D" "$R" >"$TTY"
printf -v "$__v" '%s' "$__a"
}
ui_confirm() { # prompt [y|n] -> 0 yes
local __d=${2:-n} __a __hint="y/N"
[[ $__d == y ]] && __hint="Y/n"
if ((!INTERACTIVE)); then [[ $__d == y ]]; return; fi
printf ' %s?%s %s%s%s %s[%s]%s ' "$CYN" "$R" "$B" "$1" "$R" "$D" "$__hint" "$R" >"$TTY"
IFS= read -r __a <"$TTY"; __a=${__a:-$__d}
[[ $__a == [yY]* ]]
}
# Read one keypress: prints up|down|space|enter|
ui_key() {
local __k __k2
IFS= read -rsn1 __k <"$TTY"
case $__k in
\x1b') IFS= read -rsn2 __k2 <"$TTY"
case $__k2 in '[A') echo up ;; '[B') echo down ;; *) echo esc ;; esac ;;
'') echo enter ;;
' ') echo space ;;
*) printf '%s\n' "$__k" ;;
esac
}
# Arrow-key single choice. Result: index in var.
ui_select() { # var title opts...
local __v=$1 __title=$2; shift 2
local __opts=("$@") __n=$# __cur=0 __i __k
if ((!INTERACTIVE)); then printf -v "$__v" '%s' 0; return; fi
printf ' %s?%s %s%s%s %s('"${G_UP}"'/'"${G_DOWN}"', enter)%s\n' "$CYN" "$R" "$B" "$__title" "$R" "$D" "$R" >"$TTY"
tput civis 2>/dev/null
while :; do
for ((__i = 0; __i < __n; __i++)); do
if ((__i == __cur)); then printf ' %s'"${G_PTR}"' %s%s\033[K\n' "$CYN" "${__opts[__i]}" "$R" >"$TTY"
else printf ' %s%s%s\033[K\n' "$D" "${__opts[__i]}" "$R" >"$TTY"; fi
done
__k=$(ui_key)
case $__k in
up|k) __cur=$(((__cur - 1 + __n) % __n)) ;;
down|j) __cur=$(((__cur + 1) % __n)) ;;
[1-9]) ((__k <= __n)) && __cur=$((__k - 1)) ;;
enter) break ;;
esac
printf '\033[%dA' "$__n" >"$TTY"
done
tput cnorm 2>/dev/null
printf -v "$__v" '%s' "$__cur"
}
# Arrow-key checklist. defaults: "1 0 1 ..." ; result var: "1 0 1 ..."
ui_multi() { # var title defaults opts...
local __v=$1 __title=$2 __defs=$3; shift 3
local __opts=("$@") __n=$# __cur=0 __i __k __box __ptr __any0 __sel=()
read -r -a __sel <<<"$__defs"
if ((INTERACTIVE)); then
printf ' %s?%s %s%s%s %s('"${G_UP}"'/'"${G_DOWN}"' move, space toggle, a all, enter confirm)%s\n' "$CYN" "$R" "$B" "$__title" "$R" "$D" "$R" >"$TTY"
tput civis 2>/dev/null
while :; do
for ((__i = 0; __i < __n; __i++)); do
__box="${D}${G_OFF}${R}" __ptr=" "
[[ ${__sel[__i]:-0} == 1 ]] && __box="${GRN}${G_ON}${R}"
((__i == __cur)) && __ptr="${CYN}${G_PTR}${R}"
printf ' %s %s %s\033[K\n' "$__ptr" "$__box" "${__opts[__i]}" >"$TTY"
done
__k=$(ui_key)
case $__k in
up|k) __cur=$(((__cur - 1 + __n) % __n)) ;;
down|j) __cur=$(((__cur + 1) % __n)) ;;
space) if [[ ${__sel[__cur]:-0} == 1 ]]; then __sel[__cur]=0; else __sel[__cur]=1; fi ;;
a) __any0=0; for ((__i = 0; __i < __n; __i++)); do [[ ${__sel[__i]:-0} == 0 ]] && __any0=1; done
for ((__i = 0; __i < __n; __i++)); do __sel[__i]=$__any0; done ;;
enter) break ;;
esac
printf '\033[%dA' "$__n" >"$TTY"
done
tput cnorm 2>/dev/null
fi
for ((__i = 0; __i < __n; __i++)); do __sel[__i]=${__sel[__i]:-0}; done
printf -v "$__v" '%s' "${__sel[*]}"
}
# Run a command in the background with spinner + live tail of its output.
ui_run() { # title cmd...
local title=$1; shift
local start=$SECONDS i=0 last w rc
log "RUN $title :: $*"
("$@") >>"$LOG" 2>&1 </dev/null &
local pid=$!
if [[ -t 1 ]] || ((LIVE)); then
local tick=0.12; ((LIVE)) && tick=1 # worker writes to a file: 1 update/s is plenty
tput civis 2>/dev/null
w=$(($(cols) - ${#title} - 20)); ((w < 10)) && w=10
while kill -0 "$pid" 2>/dev/null; do
last=$(tail -n 1 "$LOG" 2>/dev/null | sed s/\033\\[[0-9;?]*[A-Za-z]//g' | tr -d '\r\033' | cut -c1-"$w")
printf '\r\033[K %s%s%s %s %s%s '"${G_DOT}"' %s%s' "$CYN" "${SPIN[i % 10]}" "$R" "$title" "$D" "$(fmt_dur $((SECONDS - start)))" "$last" "$R"
i=$((i + 1)); sleep "$tick"
done
tput cnorm 2>/dev/null; printf '\r\033[K'
fi
wait "$pid"; rc=$?
if ((rc == 0)); then ok "$title ${D}($(fmt_dur $((SECONDS - start))))${R}"
else err "$title failed (exit $rc)"; tail -n 12 "$LOG" | tr -d '\000-\010\013-\037\177' | sed 's/^/ /' >&2; fi
return $rc
}
# Run a slow command with the same live spinner as ui_run, but return its
# stdout to the caller. This keeps discovery/import steps interactive without
# losing the structured output they need to parse.
ui_capture() { # result-var title cmd...
local __v=$1 title=$2; shift 2
local start=$SECONDS i=0 last w rc tmp="$CTL_DIR/capture.$.${RANDOM:-0}"
mkdir -p "$CTL_DIR" "$(dirname "$LOG")"; chmod 700 "$CTL_DIR" "$(dirname "$LOG")" 2>/dev/null
log "RUN $title :: $*"
( umask 077; : >"$tmp" )
("$@") >"$tmp" 2>>"$LOG" </dev/null &
local pid=$!
if [[ -t 1 ]] || ((LIVE)); then
local tick=0.12; ((LIVE)) && tick=1
tput civis 2>/dev/null
w=$(($(cols) - ${#title} - 20)); ((w < 10)) && w=10
while kill -0 "$pid" 2>/dev/null; do
last=$(tail -n 1 "$LOG" 2>/dev/null | sed s/\033\\[[0-9;?]*[A-Za-z]//g' | tr -d '\r\033' | cut -c1-"$w")
printf '\r\033[K %s%s%s %s %s%s '"${G_DOT}"' %s%s' "$CYN" "${SPIN[i % 10]}" "$R" "$title" "$D" "$(fmt_dur $((SECONDS - start)))" "$last" "$R"
i=$((i + 1)); sleep "$tick"
done
tput cnorm 2>/dev/null; printf '\r\033[K'
fi
wait "$pid"; rc=$?
printf -v "$__v" '%s' "$(cat "$tmp")"
rm -f "$tmp"
if ((rc == 0)); then ok "$title ${D}($(fmt_dur $((SECONDS - start))))${R}"
else err "$title failed (exit $rc)"; tail -n 12 "$LOG" | tr -d '\000-\010\013-\037\177' | sed 's/^/ /' >&2; fi
return $rc
}
# rsync --info=progress2 line -> "▕████░░░░▏ 63% · 580 MB · 87.9 MB/s · ETA 0:12"
progress_line() {
local bytes pct rate eta rest
read -r bytes pct rate eta rest <<<"$1"
pct=${pct%\%}; [[ $pct =~ ^[0-9]+$ ]] || return 0
bytes=${bytes//,/}; [[ $bytes =~ ^[0-9]+$ ]] || bytes=0
[[ $eta == *:* ]] || eta="--"
local b="" i f=$((pct * 24 / 100))
for ((i = 0; i < 24; i++)); do if ((i < f)); then b+="$G_PFULL"; else b+="$G_PEMPTY"; fi; done
printf '\r\033[K %s%s%s %3d%% %s %s %s %s %s ETA %s%s' "$CYN" "$b" "$R" "$pct" "$G_DOT" "$(hsize "$bytes")" "$G_DOT" "${rate:-?}" "$G_DOT" "$eta" "$D$R"
}
# Run a command that prints rsync-style progress (\r separated); render in place.
# Lines "@@ text" become sub-headers.
ui_stream() { # title cmd...
local title=$1; shift
local start=$SECONDS chunk l w rc
w=$(($(cols) - 8))
info "$title"
log "RUN $title :: $*"
rm -f "$CTL_DIR/rc"
{
("$@") 2>&1 </dev/null
echo "@@RC $?"
} | while IFS= read -r -d \r' chunk || [[ -n $chunk ]]; do
while IFS= read -r l; do
[[ -z $l ]] && continue
printf '%s\n' "$l" >>"$LOG"
case $l in
'@@RC '*) echo "${l#@@RC }" >"$CTL_DIR/rc" ;;
'@@ '*) printf '\r\033[K %s'"${G_SUB}"'%s %s\n' "$MAG" "$R" "$(clean "${l#@@ }")" ;;
*'%'*'/s'*|*'%'*xfr*) progress_line "$l" ;;
*rsync:*|*error*|*ERROR*|*No\ such*) printf '\r\033[K %s%s%s\n' "$YEL" "$(clean "$l" | cut -c1-"$w")" "$R" ;;
esac
done <<<"$chunk"
done
printf '\r\033[K'
rc=$(cat "$CTL_DIR/rc" 2>/dev/null || echo 1)
if [[ $rc == 0 ]]; then ok "$title ${D}($(fmt_dur $((SECONDS - start))))${R}"
else err "$title failed (exit $rc)"; fi
return "$rc"
}
# -------------------------------------------------------------- validation ---
# Everything read from a server is untrusted. Validate before it reaches
# arithmetic, a shell, SQL, rsync or the terminal.
num() { [[ $1 =~ ^[0-9]{1,18}$ ]] && printf '%s' "$1" || printf 0; }
is_ip4() { [[ $1 =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]] &&
((BASH_REMATCH[1] < 256 && BASH_REMATCH[2] < 256 && BASH_REMATCH[3] < 256 && BASH_REMATCH[4] < 256)); }
is_private_ip() { [[ $1 =~ ^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|127\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.) ]]; }
is_name() { [[ $1 =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,250}$ ]]; }
is_uuid() { [[ $1 =~ ^[A-Za-z0-9_-]{1,64}$ ]]; }
is_user() { [[ $1 =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]; }
is_port() { [[ $1 =~ ^[0-9]{1,5}$ ]] && (($1 > 0 && $1 < 65536)); }
is_fqdn() { [[ $1 =~ ^[A-Za-z0-9*]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]]; }
is_image() { [[ $1 =~ ^[a-z0-9][a-z0-9._/:@-]{0,254}$ ]]; }
is_version() { [[ $1 =~ ^[0-9A-Za-z.+-]{1,40}$ ]]; }
is_abspath() { [[ $1 =~ ^/[A-Za-z0-9._@+/-]+$ && $1 != *..* ]]; }
clean() { printf '%s' "$1" | tr -d '\000-\010\013-\037\177'; } # strip control chars before printing
# Bind mounts are only copied from data locations, never system paths.
bind_ok() {
local p=${1%/}
is_abspath "$p" || return 1
case $p in
/etc|/etc/*|/usr|/usr/*|/var|/var/lib|/var/lib/docker|/var/lib/docker/*|/var/log|/var/log/*|/var/run|/var/run/*) return 1 ;;
/root|/root/.ssh|/root/.ssh/*|/home|/home/*/.ssh|/home/*/.ssh/*|/boot|/boot/*|/opt|/srv|/mnt|/media|/data) return 1 ;;
/tmp|/tmp/*|/run|/run/*|/snap|/snap/*|/proc|/proc/*|/sys|/sys/*|/dev|/dev/*) return 1 ;;
/bin|/bin/*|/sbin|/sbin/*|/lib|/lib/*|/lib32|/lib32/*|/lib64|/lib64/*|/libx32|/libx32/*) return 1 ;;
/data/coolify|/data/coolify/*|"$RWD"|"$RWD"/*) return 1 ;;
esac
[[ $p == /home/* && $p != /home/*/* ]] && return 1 # a whole home directory
[[ $p == /*/* ]] # at least two levels deep
}
# ------------------------------------------------------------------- utils ---
hv() { local _n="${1}_${2}"; printf '%s' "${!_n}"; }
setv() { printf -v "${1}_${2}" '%s' "$3"; }
sq() { local s=${1//\'/\'\\\'\'}; printf "'%s'" "$s"; } # POSIX single-quote
def() { IFS= read -r -d '' "$1" || true; } # heredoc -> var
target() { printf '%s@%s' "$(hv "$1" USER)" "$(hv "$1" HOST)"; }
is_local() { [[ $1 == SRC && $SRC_LOCAL == 1 ]]; }
src_cmd() { if ((SRC_LOCAL)); then printf '%s' "$1"; else printf 'ssh %s "%s"' "$(target SRC)" "$1"; fi; }
US= \x1f' # field separator for records that may contain spaces
# --------------------------------------------------------------------- SSH ---
CTL_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cm.XXXXXX")" || exit 1
CTL_DIR="${CTL_DIR%/}"
# Unix socket paths are short-limited (~104 chars on macOS)
if ((${#CTL_DIR} > 60)); then rmdir "$CTL_DIR"; CTL_DIR="$(mktemp -d /tmp/cm.XXXXXX)" || exit 1; fi
chmod 700 "$CTL_DIR"
ASKPASS="$CTL_DIR/askpass"
printf '#!/bin/sh\nprintf "%%s\\n" "$CM_PW"\n' >"$ASKPASS"; chmod 700 "$ASKPASS"
ssh_minor() { ssh -V 2>&1 | sed -n 's/^OpenSSH_\([0-9]*\)\.\([0-9]*\).*/\1 \2/p'; }
password_supported() {
command -v sshpass >/dev/null 2>&1 && return 0
local v; v=$(ssh_minor); set -- $v
[[ -n ${1:-} ]] && { (($1 > 8)) || (($1 == 8 && ${2:-0} >= 4)); }
}
# First contact with a host: show its fingerprint and ask before trusting it (TOFU with consent).
approve_new_host_key() { # role fingerprints
local r=$1 fingerprints=$2 expected
expected=$(hv "$r" HOST_KEY)
if [[ -n $expected ]]; then
grep -Fq -- "$expected " <<<"$fingerprints" && return 0
printf 'Pinned host key %s does not match the key offered by the server.\n' "$expected" >&2
return 1
fi
((INTERACTIVE)) && { ui_confirm "Trust this server?" y; return; }
((ACCEPT_NEW_HOST_KEY)) && return 0
printf 'Refusing an unpinned host key in unattended mode. Use --%s-host-key SHA256:... (recommended) or --accept-new-host-key.\n' \
"$(tr A-Z a-z <<<"$r")" >&2
return 1
}
confirm_host_key() { # role
local r=$1 host port kh="$HOME/.ssh/known_hosts" spec scanned fp
host=$(hv "$r" HOST); port=$(hv "$r" PORT)
spec=$host; [[ $port != 22 ]] && spec="[$host]:$port"
mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"
if ssh-keygen -F "$spec" -f "$kh" >/dev/null 2>&1; then
if [[ -n $(hv "$r" HOST_KEY) ]]; then
fp=$(ssh-keygen -F "$spec" -f "$kh" 2>/dev/null | ssh-keygen -lf - 2>/dev/null | awk '{print $2" ("$NF")"}')
approve_new_host_key "$r" "$fp" || die "Stored host key does not match the pinned fingerprint."
fi
return 0
fi
scanned="$CTL_DIR/scan-$r"
ssh-keyscan -T 10 -p "$port" -t ed25519,ecdsa,rsa "$host" 2>/dev/null >"$scanned"
[[ -s $scanned ]] || return 0 # unreachable: the connect step will explain
fp=$(ssh-keygen -lf "$scanned" 2>/dev/null | awk '{print $2" ("$NF")"}' | head -n 3)
if ((INTERACTIVE)); then
info "First connection to ${B}$host${R}. Its SSH host key fingerprint is:"
printf '%s\n' "$fp" | sed "s/^/ ${GRN}/;s/\$/${R}/"
info "${D}(Your cloud console usually shows this in the server's boot log — compare if you want to be sure.)${R}"
fi
approve_new_host_key "$r" "$fp" || die "Host key not trusted — aborted."
((INTERACTIVE)) || log "Pinned/explicit trust accepted for new host key $spec"
cat "$scanned" >>"$kh"; chmod 600 "$kh"
}
open_master() { # role
local r=$1 t; t=$(target "$r")
local -a o=(-M -S "$CTL_DIR/$r.sock" -o ControlPersist=4h -o StrictHostKeyChecking=yes
-o ServerAliveInterval=15 -o ServerAliveCountMax=4 -o ConnectTimeout=15 -p "$(hv "$r" PORT)" -f -N)
case $(hv "$r" AUTH) in
key) o+=(-i "$(hv "$r" KEY)" -o IdentitiesOnly=yes -o PasswordAuthentication=no -o KbdInteractiveAuthentication=no)
ssh "${o[@]}" -- "$t" ;;
password)
o+=(-o PreferredAuthentications=password,keyboard-interactive -o PubkeyAuthentication=no -o NumberOfPasswordPrompts=1)
if command -v sshpass >/dev/null 2>&1; then SSHPASS="$(hv "$r" PASS)" sshpass -e ssh "${o[@]}" -- "$t"
else CM_PW="$(hv "$r" PASS)" SSH_ASKPASS="$ASKPASS" SSH_ASKPASS_REQUIRE=force DISPLAY="${DISPLAY:-:0}" ssh "${o[@]}" -- "$t" </dev/null; fi ;;
*) o+=(-o BatchMode=yes); ssh "${o[@]}" -- "$t" ;;
esac
}
open_master_logged() { open_master "$1" 2>"$2"; }
close_master() { is_local "$1" && return 0; [[ -S "$CTL_DIR/$1.sock" ]] && ssh -S "$CTL_DIR/$1.sock" -O exit -- "$(target "$1")" >/dev/null 2>&1; }
rssh() { # role args... (raw ssh over the multiplexed connection; local shell for a local source)
local r=$1; shift
if is_local "$r"; then sh -c "$*"; return; fi
[[ -S "$CTL_DIR/$r.sock" ]] || return 255
ssh -S "$CTL_DIR/$r.sock" -o ControlMaster=no -o LogLevel=ERROR -p "$(hv "$r" PORT)" -- "$(target "$r")" "$@"
}
# Run a bash script (string) on a role as root. Extra args: KEY=VALUE, exported (safely quoted) first.
rscript() { # role script [K=V...]
local r=$1 body=$2 kv pre; shift 2
pre="export LC_ALL=C RWD=$(sq "$RWD") MARKER_EPH=$(sq "$KEY_MARKER") MARKER_SETUP=$(sq "$SETUP_MARKER");" \n'
for kv in "$@"; do pre+="export ${kv%%=*}=$(sq "${kv#*=}");" \n'; done
printf '%s\n%s\n%s\n' "$pre" "$RS_PRELUDE" "$body" | rssh "$r" "$(hv "$r" SUDO) bash -s"
}
# ----------------------------------------------------------- remote scripts --
# Prepended to every remote script. Remote side is always bash on Linux.
def RS_PRELUDE <<'EOF'
set -o pipefail
sq() { local s=${1//\'/\'\\\'\'}; printf "'%s'" "$s"; }
oneline() { printf '%s' "$1" | tr -d '\000-\037\177' | head -c 1024; }
emit() { printf '%s=%s\n' "$1" "$(oneline "$2")"; }
ip4() { printf '%s' "$1" | grep -Eqx '([0-9]{1,3}\.){3}[0-9]{1,3}'; }
pubip() { local ip u
for u in https://ifconfig.io https://api.ipify.org https://ipv4.icanhazip.com; do
ip=$(curl -4fsS --max-time 6 "$u" 2>/dev/null | head -c 64 | tr -d '\r\n ')
ip4 "$ip" && { printf '%s' "$ip"; return; }
done; }
envval() { grep -E "^$1=" /data/coolify/source/.env 2>/dev/null | head -n 1 | cut -d= -f2-; }
dbu() { local v; v=$(envval DB_USERNAME); printf '%s' "${v:-coolify}"; }
dbn() { local v; v=$(envval DB_DATABASE); printf '%s' "${v:-coolify}"; }
psqlc() { docker exec coolify-db psql -U "$(dbu)" -d "$(dbn)" -AtX -v ON_ERROR_STOP=1 "$@" </dev/null; } # never reads our stdin (= this script)
psqli() { docker exec -i coolify-db psql -U "$(dbu)" -d "$(dbn)" -AtX -v ON_ERROR_STOP=1 "$@"; } # only with an explicit heredoc
journal() { mkdir -p "$RWD" && chmod 700 "$RWD" && printf '%s\n' "$*" >>"$RWD/journal"; }
homes() { { getent passwd 2>/dev/null || cat /etc/passwd; } | cut -d: -f6 | sort -u; }
EOF
def RS_DISCOVER_SRC <<'EOF'
command -v docker >/dev/null || { emit FATAL "docker is not installed"; exit 0; }
docker container inspect coolify >/dev/null 2>&1 || { emit FATAL "no 'coolify' container found - is Coolify v4 installed here?"; exit 0; }
img=$(docker container inspect -f '{{.Config.Image}}' coolify); emit VERSION "${img##*:}"
emit ARCH "$(uname -m)"; . /etc/os-release 2>/dev/null; emit OS "${PRETTY_NAME:-unknown}"
emit MID "$(cat /etc/machine-id 2>/dev/null)"
emit IP "$(pubip)"
emit DATA "$(du -sb /data/coolify 2>/dev/null | cut -f1)"
emit DBU "$(dbu)"; emit DBN "$(dbn)"
q() { psqlc -F \x1f' -c "$1" 2>/dev/null; }
IFS= \x1f' read -r lip luser kuuid <<<"$(q "select s.ip, s.\"user\", coalesce(pk.uuid,'') from servers s left join private_keys pk on pk.id=s.private_key_id where s.id=0")"
emit LOCALIP "$lip"; emit LOCALUSER "${luser:-root}"
[ -n "$kuuid" ] && [ -f "/data/coolify/ssh/keys/ssh_key@$kuuid" ] && emit LOCALPUB "$(ssh-keygen -y -f "/data/coolify/ssh/keys/ssh_key@$kuuid" 2>/dev/null)"
emit NREMOTE "$(q "select count(*) from servers where id<>0")"
lhome=$(getent passwd "${luser:-root}" 2>/dev/null | cut -d: -f6); lhome=${lhome:-/root}
[ -f "$lhome/.docker/config.json" ] && emit DOCKERCFG "$lhome/.docker/config.json"
c() { q "select count(*) from $1" || echo 0; }
dbt="standalone_postgresqls standalone_mysqls standalone_mariadbs standalone_mongodbs standalone_redis standalone_keydbs standalone_dragonflies standalone_clickhouses"
dbs=0; for t in $dbt; do n=$(c "$t"); dbs=$((dbs + ${n:-0})); done
emit COUNTS "$(c applications) apps, $(c services) services, $dbs databases, $(c servers) servers"
# resources on this server, for selective migration:
# type US uuid US name US project US environment US project_uuid
clean="translate(%s, E'\n\r\t\x1f', ' ')"
sel() { printf "select '%s', x.uuid, $clean, $clean, $clean, p.uuid" "$1" x.name p.name e.name; }
q "$(sel application) from applications x join environments e on e.id=x.environment_id join projects p on p.id=e.project_id
join standalone_dockers d on d.id=x.destination_id and x.destination_type like '%StandaloneDocker' where d.server_id=0 and x.deleted_at is null" | sed 's/^/RES=/'
q "$(sel service) from services x join environments e on e.id=x.environment_id join projects p on p.id=e.project_id where x.server_id=0 and x.deleted_at is null" | sed 's/^/RES=/'
for t in $dbt; do
q "$(sel database) from $t x join environments e on e.id=x.environment_id join projects p on p.id=e.project_id
join standalone_dockers d on d.id=x.destination_id and x.destination_type like '%StandaloneDocker' where d.server_id=0 and x.deleted_at is null" | sed 's/^/RES=/'
done
# public domains served from this server (DNS checklist)
q "select a.fqdn from applications a join standalone_dockers d on d.id=a.destination_id where a.destination_type like '%StandaloneDocker' and d.server_id=0 and a.fqdn is not null
union all select sa.fqdn from service_applications sa join services s on s.id=sa.service_id where s.server_id=0 and sa.fqdn is not null
union all select fqdn from instance_settings where fqdn is not null" | tr ',' '\n' | sed -n 's#^[a-z]*://\([^/:]*\).*#FQDN=\1#p' | sort -u
# volumes: name|bytes|in-use|mountpoint|labels(base64)|containers
docker volume ls -q | while read -r v; do
case $v in coolify-db|coolify-redis|buildx_buildkit_*) continue ;; esac
users=$(docker ps -a --filter volume="$v" --format '{{.Names}}' | tr '\n' ',' ); users=${users%,}
[ -n "$users" ] && ! printf '%s' "$users" | tr ',' '\n' | grep -qv -e '^coolify' -e '^buildx_buildkit' && continue # Coolify/BuildKit internals
mp=$(docker volume inspect -f '{{.Mountpoint}}' "$v")
lb=$(docker volume inspect -f '{{range $k,$v := .Labels}}{{$k}}={{$v}}{{"\n"}}{{end}}' "$v" | base64 | tr -d '\n')
printf 'VOL=%s|%s|%s|%s|%s|%s\n' "$(oneline "$v")" "$(du -sb "$mp" 2>/dev/null | cut -f1)" "${users:+1}" "$(oneline "$mp")" "$lb" "$(oneline "$users")"
done
ids=$(docker ps -aq --filter label=coolify.managed=true)
if [ -n "$ids" ]; then
for id in $ids; do
n=$(docker inspect -f '{{.Name}}' "$id" | sed 's#^/##')
docker inspect -f '{{range .Mounts}}{{if eq .Type "bind"}}{{.Source}}{{"\n"}}{{end}}{{end}}' "$id" | while read -r p; do
[ -n "$p" ] && [ -e "$p" ] && printf 'BIND=%s|%s|%s\n' "$(oneline "$p")" "$(du -sb "$p" 2>/dev/null | cut -f1)" "$(oneline "$n")"
done
done
docker inspect -f '{{.Image}}' $ids | sort -u >"$RWD/imgids" 2>/dev/null || { mkdir -p "$RWD" && chmod 700 "$RWD" && docker inspect -f '{{.Image}}' $ids | sort -u >"$RWD/imgids"; }
docker images --no-trunc --format '{{.ID}} {{.Repository}}:{{.Tag}}' | grep -v '' | while read -r id ref; do
grep -qx "$id" "$RWD/imgids" && printf 'IMG=%s|%s\n' "$(oneline "$ref")" "$(docker image inspect -f '{{.Size}}' "$ref")"
done
rm -f "$RWD/imgids"
fi
docker ps --format '{{.Names}}|{{.Image}}' | sed 's/^/RUN=/'
true
EOF
# SOURCE CONTROL PLANE: inventory workloads that actually run on managed
# servers. The source Coolify already owns the SSH keys used for these hosts.
def RS_DISCOVER_MANAGED <<'EOF'
q() { psqlc -F \x1f' -c "$1" 2>/dev/null; }
clean="translate(%s, E'\\n\\r\\t\\x1f', ' ')"
sel() { printf "select '%s', x.uuid, $clean, $clean, $clean, %s, s.name" "$1" x.name p.name e.name "$2"; }
q "$(sel application d.server_id) from applications x join environments e on e.id=x.environment_id join projects p on p.id=e.project_id
join standalone_dockers d on d.id=x.destination_id and x.destination_type like '%StandaloneDocker'
join servers s on s.id=d.server_id where d.server_id<>0 and x.deleted_at is null" |
while IFS= \x1f' read -r typ uuid name project env sid sname; do
printf 'MRES=%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\n' "$typ" "$uuid" "$name" "$project" "$env" "$sid" "$sname"
done
q "$(sel application sd.server_id) from applications x join environments e on e.id=x.environment_id join projects p on p.id=e.project_id
join swarm_dockers sd on sd.id=x.destination_id and x.destination_type like '%SwarmDocker'
join servers s on s.id=sd.server_id where sd.server_id<>0 and x.deleted_at is null" |
while IFS= \x1f' read -r typ uuid name project env sid sname; do
printf 'MRES=%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\n' "$typ" "$uuid" "$name" "$project" "$env" "$sid" "$sname"
done
q "$(sel service x.server_id) from services x join environments e on e.id=x.environment_id join projects p on p.id=e.project_id
join servers s on s.id=x.server_id where x.server_id<>0 and x.deleted_at is null" |
while IFS= \x1f' read -r typ uuid name project env sid sname; do
printf 'MRES=%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\n' "$typ" "$uuid" "$name" "$project" "$env" "$sid" "$sname"
done
for t in standalone_postgresqls standalone_mysqls standalone_mariadbs standalone_mongodbs standalone_redis standalone_keydbs standalone_dragonflies standalone_clickhouses; do
q "$(sel database d.server_id) from $t x join environments e on e.id=x.environment_id join projects p on p.id=e.project_id
join standalone_dockers d on d.id=x.destination_id and x.destination_type like '%StandaloneDocker'
join servers s on s.id=d.server_id where d.server_id<>0 and x.deleted_at is null" |
while IFS= \x1f' read -r typ uuid name project env sid sname; do
printf 'MRES=%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\n' "$typ" "$uuid" "$name" "$project" "$env" "$sid" "$sname"
done
done
q "select a.fqdn from applications a join standalone_dockers d on d.id=a.destination_id where a.destination_type like '%StandaloneDocker' and d.server_id<>0 and a.fqdn is not null
union all select sa.fqdn from service_applications sa join services s on s.id=sa.service_id where s.server_id<>0 and sa.fqdn is not null" |
tr ',' '\n' | sed -n 's#^[a-z]*://\([^/:]*\).*#FQDN=\1#p' | sort -u
q "select a.fqdn from applications a join swarm_dockers sd on sd.id=a.destination_id where a.destination_type like '%SwarmDocker' and sd.server_id<>0 and a.fqdn is not null" |
tr ',' '\n' | sed -n 's#^[a-z]*://\([^/:]*\).*#FQDN=\1#p' | sort -u
mkdir -p "$RWD"; touch "$RWD/managed_known_hosts"; chmod 600 "$RWD/managed_known_hosts"
q "select s.id, s.name, s.ip, coalesce(s.port,22), coalesce(nullif(s.\"user\",''),'root'), pk.uuid
from servers s join private_keys pk on pk.id=s.private_key_id where s.id<>0 order by s.id" |
while IFS= \x1f' read -r sid name ip port user keyuuid; do
printf '%s' "$sid" | grep -Eqx '[0-9]+' || continue
printf '%s' "$name" | grep -Eqx '[A-Za-z0-9][A-Za-z0-9_.-]{0,250}' || { emit MWARN "server $sid has an unsupported name"; continue; }
printf '%s' "$ip" | grep -Eqx '[A-Za-z0-9.:-]{1,253}' || { emit MWARN "$name has an invalid address"; continue; }
printf '%s' "$port" | grep -Eqx '[0-9]{1,5}' || { emit MWARN "$name has an invalid port"; continue; }
printf '%s' "$user" | grep -Eqx '[a-z_][a-z0-9_-]{0,31}' || { emit MWARN "$name has an invalid user"; continue; }
printf '%s' "$keyuuid" | grep -Eqx '[A-Za-z0-9_-]{1,64}' || { emit MWARN "$name has no usable SSH key"; continue; }
key="/data/coolify/ssh/keys/ssh_key@$keyuuid"
[ -f "$key" ] || { emit MWARN "$name SSH key is missing"; continue; }
probe="$RWD/managed-probe-$sid-$"
if ! ssh -i "$key" -p "$port" -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
-o UserKnownHostsFile="$RWD/managed_known_hosts" -o ConnectTimeout=12 -o LogLevel=ERROR -- "$user@$ip" \
'if [ "$(id -u)" = 0 ]; then exec bash -s; else exec sudo -n bash -s; fi' >"$probe" 2>/dev/null <<'REMOTE'
set -o pipefail
one() { printf '%s' "$1" | tr -d '\000-\037\177' | head -c 1024; }
printf 'ARCH|%s\n' "$(uname -m)"
docker volume ls -q | while read -r v; do
case "$v" in coolify-db|coolify-redis|buildx_buildkit_*) continue ;; esac
users=$(docker ps -a --filter volume="$v" --format '{{.Names}}' | tr '\n' ','); users=${users%,}
[ -n "$users" ] && ! printf '%s' "$users" | tr ',' '\n' | grep -qv -e '^coolify' -e '^buildx_buildkit' && continue
mp=$(docker volume inspect -f '{{.Mountpoint}}' "$v")
lb=$(docker volume inspect -f '{{range $k,$v := .Labels}}{{$k}}={{$v}}{{"\n"}}{{end}}' "$v" | base64 | tr -d '\n')
printf 'VOL|%s|%s|%s|%s|%s|%s\n' "$(one "$v")" "$(du -sb "$mp" 2>/dev/null | cut -f1)" "${users:+1}" "$(one "$mp")" "$lb" "$(one "$users")"
done
ids=$(docker ps -aq --filter label=coolify.managed=true)
if [ -n "$ids" ]; then
for id in $ids; do
n=$(docker inspect -f '{{.Name}}' "$id" | sed 's#^/##')
docker inspect -f '{{range .Mounts}}{{if eq .Type "bind"}}{{.Source}}{{"\n"}}{{end}}{{end}}' "$id" |
while read -r p; do [ -n "$p" ] && [ -e "$p" ] && printf 'BIND|%s|%s|%s\n' "$(one "$p")" "$(du -sb "$p" 2>/dev/null | cut -f1)" "$(one "$n")"; done
done
tmp=/tmp/cm-managed-imgids-$
docker inspect -f '{{.Image}}' $ids | sort -u >"$tmp"
docker images --no-trunc --format '{{.ID}} {{.Repository}}:{{.Tag}}' | grep -v '' |
while read -r id ref; do grep -qx "$id" "$tmp" && printf 'IMG|%s|%s\n' "$(one "$ref")" "$(docker image inspect -f '{{.Size}}' "$ref")"; done
rm -f "$tmp"
fi
docker ps --format '{{.Names}}|{{.Image}}' | sed 's/^/RUN|/'
REMOTE
then rm -f "$probe"; emit MWARN "$name is unreachable or lacks passwordless sudo"; continue
fi
remote=$(cat "$probe"); rm -f "$probe"
arch=$(printf '%s\n' "$remote" | sed -n 's/^ARCH|//p' | head -n 1)
printf '%s' "$arch" | grep -Eqx '[a-z0-9_]{1,16}' || { emit MWARN "$name returned an invalid architecture"; continue; }
printf 'MSERVER=%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\n' "$sid" "$name" "$ip" "$port" "$user" "$keyuuid" "$arch"
while IFS='|' read -r kind a b c d e f; do
case "$kind" in
VOL) printf 'MVOL=%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\n' "$sid" "$name" "$a" "$b" "$c" "$d" "$e" "$f" ;;
BIND) printf 'MBIND=%s\x1f%s\x1f%s\x1f%s\x1f%s\n' "$sid" "$name" "$a" "$b" "$c" ;;
IMG) printf 'MIMG=%s\x1f%s\x1f%s\x1f%s\n' "$sid" "$name" "$a" "$b" ;;
RUN) printf 'MRUN=%s\x1f%s\x1f%s\x1f%s\n' "$sid" "$name" "$a" "$b" ;;
esac
done <<<"$remote"
done
true
EOF
def RS_DISCOVER_DST <<'EOF'
emit ARCH "$(uname -m)"; . /etc/os-release 2>/dev/null; emit OS "${PRETTY_NAME:-unknown}"
emit MID "$(cat /etc/machine-id 2>/dev/null)"
emit IP "$(pubip)"
mkdir -p /var/lib; emit FREE "$(df -Pk /var/lib | awk 'NR==2{printf "%.0f\n",$4*1024}')"
emit INODES "$(df -Pi /var/lib | awk 'NR==2{print $4}')"
emit CPU "$(getconf _NPROCESSORS_ONLN 2>/dev/null || nproc 2>/dev/null || echo 0)"
emit MEMTOTAL "$(awk '/^MemTotal:/{printf "%.0f\n",$2*1024}' /proc/meminfo 2>/dev/null)"
command -v docker >/dev/null && emit HASDOCKER 1
if command -v docker >/dev/null && docker container inspect coolify >/dev/null 2>&1; then
img=$(docker container inspect -f '{{.Config.Image}}' coolify); emit COOLIFY "${img##*:}"
emit COOLIFYUSERS "$(psqlc -c 'select count(*) from users' 2>/dev/null)"
psqlc -F \x1f' -c "select uuid, translate(name, E'\\n\\r\\t\\x1f', ' ') from projects where team_id=0 order by name" 2>/dev/null | sed 's/^/DPROJECT=/'
fi
[ -d /data/coolify ] && emit DATA 1
[ -f "$RWD/journal" ] && emit JOURNAL "$(grep -c . "$RWD/journal")"
true
EOF
def RS_PREPARE <<'EOF'
mkdir -p "$RWD/dump" && chmod 700 "$RWD"
pkg() {
if command -v apt-get >/dev/null; then DEBIAN_FRONTEND=noninteractive apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$@"
elif command -v dnf >/dev/null; then dnf install -y -q "$@"
elif command -v yum >/dev/null; then yum install -y -q "$@"
elif command -v zypper >/dev/null; then zypper -n -q in "$@"
elif command -v apk >/dev/null; then apk add -q "$@"
elif command -v pacman >/dev/null; then pacman -Sy --noconfirm "$@"
fi
}
need=""; for t in rsync curl; do command -v $t >/dev/null || need="$need $t"; done
command -v zstd >/dev/null || need="$need zstd"
for t in timeout sha256sum; do command -v $t >/dev/null || need="$need coreutils"; done
[ -n "$need" ] && { echo "installing:$need"; pkg $need </dev/null || true; }
for t in rsync curl timeout sha256sum; do command -v $t >/dev/null || { echo "missing $t and could not install it"; exit 1; }; done
echo ready
EOF
# Records what the destination looked like before we touched it (first run only).
def RS_JOURNAL_INIT <<'EOF'
mkdir -p "$RWD" && chmod 700 "$RWD"
if [ ! -f "$RWD/journal" ]; then
journal "ENGINE $ENGINE"
command -v docker >/dev/null && journal "DOCKER_PREEXISTED 1" || journal "DOCKER_PREEXISTED 0"
{ [ "$OVERWRITE" != 1 ] && command -v docker >/dev/null && docker container inspect coolify >/dev/null 2>&1; } && journal "COOLIFY_PREEXISTED 1" || journal "COOLIFY_PREEXISTED 0"
[ -d /data/coolify ] && journal "DATA_PREEXISTED 1" || journal "DATA_PREEXISTED 0"
fi
cat "$RWD/journal"
EOF
def RS_KEYGEN <<'EOF'
mkdir -p "$RWD" && chmod 700 "$RWD"
[ -f "$RWD/id_ed25519" ] || ssh-keygen -q -t ed25519 -N '' -C "$MARKER_EPH" -f "$RWD/id_ed25519" </dev/null
cat "$RWD/id_ed25519.pub"
EOF
# Adds a key for TUSER, restricted and with an expiry date. OPTS may be empty.
def RS_AUTHORIZE <<'EOF'
printf '%s' "$TUSER" | grep -Eqx '[a-z_][a-z0-9_-]{0,31}' || { echo "invalid user"; exit 1; }
h=$(getent passwd "$TUSER" 2>/dev/null | cut -d: -f6)
[ -n "$h" ] || h=$(awk -F: -v u="$TUSER" '$1==u{print $6}' /etc/passwd)
[ -n "$h" ] && [ -d "$h" ] || { echo "user $TUSER not found"; exit 1; }
mkdir -p "$h/.ssh"; touch "$h/.ssh/authorized_keys"
line="$PUB"; [ -n "$OPTS" ] && line="$OPTS $PUB"
grep -qF -- "$PUB" "$h/.ssh/authorized_keys" || printf '%s\n' "$line" >>"$h/.ssh/authorized_keys"
chown -- "$TUSER" "$h/.ssh" "$h/.ssh/authorized_keys" 2>/dev/null
chmod 700 "$h/.ssh"; chmod 600 "$h/.ssh/authorized_keys"
[ -n "$JOURNAL_IT" ] && journal "AUTHKEY $TUSER $PUB"
echo authorized
EOF
def RS_UNAUTHORIZE <<'EOF'
homes | while read -r h; do
f="$h/.ssh/authorized_keys"
[ -f "$f" ] && grep -q -- "$MARKER" "$f" && sed -i "/$MARKER/d" "$f"
done
true
EOF
def RS_TEST_DIRECT <<'EOF'
$DSSH -n -o ConnectTimeout=10 -- "$DT" true && echo DIRECT_OK
EOF
# Runs on SOURCE: rsync each record "label US src US dst US mode US volume" straight to the destination.
# mode: "delete" mirrors (removes extra files), "freeze" pauses the containers using the volume during the copy.
def RS_RSYNC <<'EOF'
set -f
args=(-aHS --numeric-ids --info=progress2 --no-inc-recursive --partial --partial-dir=.cm-partial -s -e "$DSSH")
[ -n "$DS" ] && args+=(--rsync-path="$DS rsync")
if [ "$ZIP" = 1 ]; then
args+=(-z)
if rsync --version 2>/dev/null | grep -qi zstd &&
$DSSH -n -- "$DT" "$DS rsync --version" 2>/dev/null | grep -qi zstd; then
args+=(--compress-choice=zstd)
fi
fi
for x in $EXCLUDES; do args+=(--exclude="$x"); done
copy_one() (
label=$1 src=$2 dst=$3 mode=$4 vol=$5 paused="" r=0
extra=(); case "$mode" in *delete*) extra+=(--delete) ;; esac
unfreeze() { [ -n "$paused" ] && docker unpause $paused >/dev/null 2>&1; paused=""; }
trap unfreeze EXIT INT TERM HUP
run_rsync() {
if [ -n "$paused" ]; then
timeout "$FREEZE_TIMEOUT" rsync "$@" || { r=$?; [ $r = 124 ] && echo "@@ freeze limit reached (${FREEZE_TIMEOUT}s); transfer aborted safely"; return $r; }
else rsync "$@"; fi
}
case "$dst" in ''|/|/etc|/etc/*|/usr|/var|/var/lib|/var/lib/docker|/root|/home|/boot|/bin|/sbin|/lib|/lib64|/opt|/srv)
echo "@@ refusing unsafe destination path '$dst' ($label)"; exit 1 ;; esac
case "$src" in ''|/) echo "@@ refusing unsafe source path '$src' ($label)"; exit 1 ;; esac
echo "@@ $label"
case "$mode" in *freeze*)
ids=$(docker ps -q --filter "volume=$vol" --filter status=running 2>/dev/null | tr '\n' ' ')
if [ -n "${ids// /}" ]; then
docker pause $ids >/dev/null 2>&1 && paused=$ids && echo "@@ froze $(echo $ids | wc -w) container(s), limit ${FREEZE_TIMEOUT}s"
nohup sh -c "sleep $((FREEZE_TIMEOUT + 10)); docker unpause $ids" >/dev/null 2>&1 </dev/null &
fi ;; esac
if [ -d "$src" ]; then
$DSSH -n -- "$DT" "$DS mkdir -p -- $(sq "$dst")" || exit 1
run_rsync "${args[@]}" "${extra[@]}" "$src/" "$DT:$dst/" </dev/null || r=$?
elif [ -e "$src" ]; then
$DSSH -n -- "$DT" "$DS mkdir -p -- $(sq "$(dirname "$dst")")" || exit 1
run_rsync "${args[@]}" "$src" "$DT:$dst" </dev/null || r=$?
fi
[ -n "$paused" ] && echo "@@ unfroze"
[ $r = 24 ] && r=0
exit $r
)
rc=0 pids="" job_count=0
flush_jobs() {
local p
for p in $pids; do wait "$p" || rc=1; done
pids=""; job_count=0
}
while IFS= \x1f' read -r label src dst mode vol; do
[ -z "$label" ] && continue
case "$mode" in *freeze*) flush_jobs; copy_one "$label" "$src" "$dst" "$mode" "$vol" || rc=1 ;;
*) copy_one "$label" "$src" "$dst" "$mode" "$vol" & pids="$pids $!"; job_count=$((job_count + 1));
[ "$job_count" -ge "$JOBS" ] && flush_jobs ;;
esac
done <<<"$PAIRS"
flush_jobs
exit $rc
EOF
def RS_SAVE_IMAGES_DIRECT <<'EOF'
set -f
docker save $IMGS | $COMP | $DSSH -- "$DT" "$DS sh -c '$DECOMP | docker load'"
EOF
def RS_INSTALL <<'EOF'
mkdir -p "$RWD" && chmod 700 "$RWD"
curl -fsSL "$URL" -o "$RWD/install.sh" || { echo "could not download the Coolify installer"; exit 1; }
if [ -n "$INSTALL_SHA256" ]; then
actual=$(sha256sum "$RWD/install.sh" | awk '{print $1}')
[ "$actual" = "$INSTALL_SHA256" ] || { echo "installer checksum mismatch"; exit 1; }
fi
journal "INSTALLED_COOLIFY 1"
# Lab/odd hosts: a pre-existing 'coolify' network is reused by the installer.
bash "$RWD/install.sh" "$CV" </dev/null
echo "installer exit code: $? (health is verified separately)"
true
EOF
def RS_WAIT_HEALTHY <<'EOF'
port=$(envval APP_PORT); port=${port:-8000}
for i in $(seq 1 120); do
curl -fsS "http://127.0.0.1:$port/api/health" >/dev/null 2>&1 && { echo "healthy on :$port"; exit 0; }
[ $((i % 6)) = 0 ] && echo "waiting for Coolify ($((i * 5))s)"
sleep 5
done
exit 1
EOF
def RS_CREATE_VOLUMES <<'EOF'
while IFS='|' read -r name labels; do
[ -z "$name" ] && continue
printf '%s' "$name" | grep -Eqx '[A-Za-z0-9][A-Za-z0-9_.-]*' || { echo "skip invalid volume name"; continue; }
if ! docker volume inspect "$name" >/dev/null 2>&1; then
largs=()
while IFS= read -r l; do [ -n "$l" ] && largs+=(--label "$l"); done <<<"$(printf '%s' "$labels" | base64 -d 2>/dev/null)"
docker volume create "${largs[@]}" "$name" >/dev/null || { echo "failed $name"; exit 1; }
journal "VOLUME $name"
fi
echo "MP=$name|$(docker volume inspect -f '{{.Mountpoint}}' "$name")"
done <<<"$SPEC"
EOF
def RS_DUMP <<'EOF'
mkdir -p "$RWD/dump" && chmod 700 "$RWD" "$RWD/dump"
docker exec coolify-db pg_dump -U "$(dbu)" -d "$(dbn)" -Fc >"$RWD/dump/coolify.dump" && chmod 600 "$RWD/dump/coolify.dump"
ls -lh "$RWD/dump/coolify.dump" | awk '{print "dump size:", $5}'
EOF
# DESTINATION (clone engine): restore the control plane from the dump.
def RS_RESTORE <<'EOF'
docker stop coolify coolify-realtime >/dev/null 2>&1
docker exec -i coolify-db pg_restore --clean --if-exists --no-acl --no-owner -U "$(dbu)" -d "$(dbn)" <"$RWD/dump/coolify.dump" 2>&1 |
grep -E '^pg_restore: (error|warning)' | grep -v 'does not exist' | cut -c1-200 | tail -n 20
n=$(psqlc -c "select count(*) from servers" 2>/dev/null)
[ "${n:-0}" -ge 1 ] || { echo "restore verification failed"; exit 1; }
echo "restored: $n servers"
[ "$FIXIP" = 1 ] && psqlc -c "update servers set ip='host.docker.internal' where id=0" >/dev/null && echo " localhost server ip -> host.docker.internal"
if ip4 "$OLDIP" && ip4 "$NEWIP" && [ "$OLDIP" != "$NEWIP" ]; then
psqli -v old="$OLDIP" -v new="$NEWIP" >/dev/null 2>&1 <<'SQL' && echo " instance public IP updated"
update instance_settings set public_ipv4 = :'new' where public_ipv4 = :'old';
SQL
fi
# Copy mode: nothing scheduled may run twice. Pause what's enabled, remember it for --cutover.
if [ "$PAUSE" = 1 ]; then
: >"$RWD/paused-tasks"; chmod 600 "$RWD/paused-tasks"
for t in scheduled_tasks scheduled_database_backups scheduled_volume_backups; do
psqlc -c "select '$t ' || id from $t where enabled" 2>/dev/null >>"$RWD/paused-tasks"
psqlc -c "update $t set enabled=false where enabled" >/dev/null 2>&1
done
echo " paused $(grep -c . "$RWD/paused-tasks") scheduled tasks/backups until cutover"
fi
rm -rf /data/coolify/ssh/mux/* 2>/dev/null
curl -fsSL "$URL" -o "$RWD/install.sh" || exit 1
if [ -n "$INSTALL_SHA256" ]; then
actual=$(sha256sum "$RWD/install.sh" | awk '{print $1}')
[ "$actual" = "$INSTALL_SHA256" ] || { echo "installer checksum mismatch"; exit 1; }
fi
bash "$RWD/install.sh" "$CV" </dev/null
echo "installer exit code: $? (health is verified separately)"
true
EOF
# DESTINATION: re-enable scheduled tasks/backups paused by copy mode.
def RS_RESUME_TASKS <<'EOF'
[ -f "$RWD/paused-tasks" ] || { echo "nothing paused"; exit 0; }
n=0
while read -r t id; do
case "$t" in scheduled_tasks|scheduled_database_backups|scheduled_volume_backups) ;; *) continue ;; esac
printf '%s' "$id" | grep -Eqx '[0-9]+' || continue
psqlc -c "update $t set enabled=true where id=$id" >/dev/null 2>&1 && n=$((n + 1))
done <"$RWD/paused-tasks"
rm -f "$RWD/paused-tasks"
echo "re-enabled $n scheduled tasks/backups"
EOF
# DESTINATION: run a PHP file inside the coolify container (file content in $PHP).
def RS_PHP <<'EOF'
mkdir -p "$RWD" && chmod 700 "$RWD"
f="$RWD/run-$.php"; printf '%s' "$PHP" >"$f"; chmod 600 "$f"
trap 'rm -f "$f"; docker exec -u 0 coolify rm -f /tmp/cm-run.php /tmp/cm-in.json /tmp/cm-out.json >/dev/null 2>&1' EXIT
owner="$(docker exec coolify id -u):$(docker exec coolify id -g)" # php runs as this user (www-data)
give() { docker cp "$1" "coolify:$2" >/dev/null && docker exec -u 0 coolify sh -c "chown $owner $2 && chmod 600 $2"; }
[ -n "$INFILE" ] && [ -f "$RWD/$INFILE" ] && { give "$RWD/$INFILE" /tmp/cm-in.json || exit 1; }
give "$f" /tmp/cm-run.php || exit 1
docker exec -e CM_ARGS="$ARGS" -e CM_REUSE_IMAGES="$REUSE_IMAGES" -e OLDIP="$OLDIP" -e NEWIP="$NEWIP" \
-e TARGET_PROJECT_UUID="$TARGET_PROJECT_UUID" -e PROJECT_MAP_B64="$PROJECT_MAP_B64" coolify php /tmp/cm-run.php "$@"
rc=$?
if [ -n "$OUTFILE" ]; then docker cp "coolify:/tmp/cm-out.json" "$RWD/$OUTFILE" >/dev/null 2>&1 && chmod 600 "$RWD/$OUTFILE"; fi
exit $rc
EOF
# SOURCE, at cutover: stop what moves (and Coolify itself for a full clone). Every change is recorded in rollback.sh.
def RS_STOP_SOURCE <<'EOF'
mkdir -p "$RWD" && chmod 700 "$RWD"
rb="$RWD/rollback.sh"
[ -f "$rb" ] || { echo '#!/usr/bin/env bash'; echo '# coolify-migrate: restores the OLD server to how it was before cutover.'
echo 'docker rm -f cm-bridge >/dev/null 2>&1; rm -f /etc/cron.d/coolify-migrate-bridge'; } >"$rb"
rec() { p=$(docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' "$1" 2>/dev/null) || return 0
grep -q -- " $1; docker start $1\$" "$rb" || echo "docker update --restart=$p $1; docker start $1" >>"$rb"; }
stop=""
for n in $NAMES; do
printf '%s' "$n" | grep -Eqx '[A-Za-z0-9][A-Za-z0-9_.-]*' || continue
docker container inspect "$n" >/dev/null 2>&1 || continue
rec "$n"; docker update --restart=no "$n" >/dev/null; stop="$stop $n"
done
[ -n "$stop" ] && docker stop -t 30 $stop >/dev/null && echo "stopped:$stop" # in parallel
chmod 700 "$rb"; echo "rollback script: $rb"
EOF
# SOURCE CONTROL PLANE: stream a volume or bind directory from the managed
# server that owns it. The stream is compressed on that host and contains no
# shell-interpreted path data.
def RS_MANAGED_TAR <<'EOF'
printf '%s' "$SID" | grep -Eqx '[0-9]+' || exit 2
case "$PATH_TO_COPY" in /*/*) ;; *) echo "invalid managed source path" >&2; exit 2 ;; esac
printf '%s' "$VOLUME" | grep -Eqx '[A-Za-z0-9_.-]*' || exit 2
row=$(psqlc -F \x1f' -c "select s.ip,coalesce(s.port,22),coalesce(nullif(s.\"user\",''),'root'),pk.uuid from servers s join private_keys pk on pk.id=s.private_key_id where s.id=$SID" 2>/dev/null)
IFS= \x1f' read -r ip port user keyuuid <<<"$row"
printf '%s' "$ip" | grep -Eqx '[A-Za-z0-9.:-]{1,253}' || exit 2
printf '%s' "$port" | grep -Eqx '[0-9]{1,5}' || exit 2
printf '%s' "$user" | grep -Eqx '[a-z_][a-z0-9_-]{0,31}' || exit 2
printf '%s' "$keyuuid" | grep -Eqx '[A-Za-z0-9_-]{1,64}' || exit 2
key="/data/coolify/ssh/keys/ssh_key@$keyuuid"; [ -f "$key" ] || exit 2
ssh -i "$key" -p "$port" -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$RWD/managed_known_hosts" \
-o ConnectTimeout=12 -o LogLevel=ERROR -- "$user@$ip" \
"if [ \"\$(id -u)\" = 0 ]; then exec bash -s -- $(sq "$PATH_TO_COPY") $(sq "$VOLUME") $FREEZE $FREEZE_TIMEOUT; else exec sudo -n bash -s -- $(sq "$PATH_TO_COPY") $(sq "$VOLUME") $FREEZE $FREEZE_TIMEOUT; fi" <<'REMOTE'
set -o pipefail
path=$1 vol=$2 freeze=$3 limit=$4 paused=""
case "$path" in /*/*) ;; *) exit 2 ;; esac
case "$path" in /|/etc|/usr|/var|/var/lib|/var/lib/docker|/root|/home|/boot|/bin|/sbin|/lib|/lib64|/opt|/srv) exit 2 ;; esac
unfreeze() { [ -n "$paused" ] && docker unpause $paused >/dev/null 2>&1; paused=""; }
trap unfreeze EXIT INT TERM HUP
if [ "$freeze" = 1 ] && [ -n "$vol" ]; then
ids=$(docker ps -q --filter "volume=$vol" --filter status=running | tr '\n' ' ')
if [ -n "${ids// /}" ]; then docker pause $ids >/dev/null && paused=$ids; fi
fi
timeout "$limit" tar -C "$path" --numeric-owner -cpf - . | gzip -1
rc=${PIPESTATUS[0]}; [ "$rc" = 124 ] && echo "freeze limit reached" >&2
exit "$rc"
REMOTE
EOF
# SOURCE CONTROL PLANE: place the one-day transfer key and pinned destination
# host keys on one managed server, then verify its direct route to destination.
def RS_MANAGED_SETUP <<'EOF'
printf '%s' "$SID" | grep -Eqx '[0-9]+' || exit 2
printf '%s' "$REACH" | grep -Eqx '[A-Za-z0-9.-]{1,253}' || exit 2
printf '%s' "$RPORT" | grep -Eqx '[0-9]{1,5}' || exit 2
printf '%s' "$DUSER" | grep -Eqx '[a-z_][a-z0-9_-]{0,31}' || exit 2
case "$DSUDO" in ''|sudo) ;; *) exit 2 ;; esac
row=$(psqlc -F \x1f' -c "select s.ip,coalesce(s.port,22),coalesce(nullif(s.\"user\",''),'root'),pk.uuid from servers s join private_keys pk on pk.id=s.private_key_id where s.id=$SID" 2>/dev/null)
IFS= \x1f' read -r ip port user keyuuid <<<"$row"; key="/data/coolify/ssh/keys/ssh_key@$keyuuid"
[ -f "$key" ] && [ -f "$RWD/id_ed25519" ] && [ -f "$RWD/known_hosts" ] || exit 2
base=(ssh -i "$key" -p "$port" -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$RWD/managed_known_hosts" -o ConnectTimeout=12 -o LogLevel=ERROR -- "$user@$ip")
"${base[@]}" 'if [ "$(id -u)" = 0 ]; then exec bash -s; else exec sudo -n bash -s; fi' <<'REMOTE' || exit 1
set -o pipefail
mkdir -p /var/lib/coolify-migrate; chmod 700 /var/lib/coolify-migrate
pkg() {
if command -v apt-get >/dev/null; then DEBIAN_FRONTEND=noninteractive apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$@"
elif command -v dnf >/dev/null; then dnf install -y -q "$@"
elif command -v yum >/dev/null; then yum install -y -q "$@"
elif command -v apk >/dev/null; then apk add -q "$@"
fi
}
need=""; command -v rsync >/dev/null || need="$need rsync"; command -v timeout >/dev/null || need="$need coreutils"
[ -n "$need" ] && pkg $need </dev/null
command -v rsync >
添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论