Moving your app somewhere else: opscrew retarget
opscrew retarget moves a live app, with its data, to another place: Hetzner Cloud, DigitalOcean, Contabo,
a server you already have or a Portainer you already run,
in any direction. That includes moving from one server of yours to another. A site moves between
Cloudflare’s hosting and GitHub Pages.
The new side is set up next to the old one, the app is deployed and checked there, its data is copied in a maintenance window, and then your DNS records switch to the new side. The old side stays until you say the new one is good.
npx opscrew retarget --to digitalocean # start the move, or continue it after a stopnpx opscrew retarget --finish # after the switch: remove the old sidenpx opscrew retarget --abort # before the switch: back to the old side--to |
Moves the app to |
|---|---|
hetzner, digitalocean, contabo |
A new server at that cloud provider |
own |
A server you already have (OpsCrew asks how to reach it) |
portainer |
An environment in the Portainer you run (OpsCrew asks for it) |
cloudflare-workers, github-pages |
The other site host, for a site |
Without --to, the target in your answers (opscrew/requirements.yaml) is used. Moves run only in
your terminal; in Claude Code, opscrew_status shows where a move stands. Like every command, it
takes --app <key> and --env <name> for another app or
environment.
Some moves aren’t possible yet; see What can’t move yet.
Before you move
Section titled “Before you move”- The tokens of both sides:
HCLOUD_TOKENfor Hetzner,DIGITALOCEAN_TOKENfor DigitalOcean, the fourCNTB_OAUTH2_*values for Contabo (see the credentials),PORTAINER_API_KEYfor Portainer, plus your GitHub token, andCLOUDFLARE_API_TOKENwhen your domain’s DNS is on Cloudflare. A missing or rejected token stops the move before anything changes. In Portainer, use the token of the user who deployed the app’s stacks (OpsCrew’s own token for them), or an administrator’s: a standard user’s token can’t stop containers another user created. - This computer: run the move on the computer that deployed the app. It has the infrastructure state, the SSH keys and the database’s password, and the data passes through it, so a fast connection shortens the window.
- Public SSH open: servers are reached over public SSH before they join the
app’s private network. If you closed it, open it with
npx opscrew secure --open-public-ssh, move, then close it again withnpx opscrew secure --close-public-ssh. - Your DNS, if it isn’t on Cloudflare: lower the TTL of the app’s records (for example to 60 seconds) beforehand, so the switch reaches your visitors quickly (see Without DNS on Cloudflare).
- The cost: both sides run until you finish the move. A new cloud server is billed from its
creation, and the old one until
--finishdeletes it. The summary shows the new side’s monthly price before anything changes; your own server and your Portainer have none. A Contabo server is bought for at least a month, and one that--finishremoves is billed to the end of its term (see Removing the app).
How a move works
Section titled “How a move works”npx opscrew retarget --to ownOpsCrew first checks that the app can run on the new side and stops, with nothing changed, when it can’t: a processor architecture OpsCrew builds no image for, too little memory or disk, or the same place the app already runs. Then it reads the size of each piece of data on the old side, asks whether to copy each one or start it empty (see The data), and shows a summary: each step, the extra monthly cost, the data and its size, the bucket, and the maintenance window’s expected length. Nothing changes until you continue.
-
Start. Pick the new target:
- a cloud provider: OpsCrew offers its servers for the region and budget from your
interview, as
opscrew designdoes, and you pick one; - your own server: name it (address, SSH port, login and key, how visitors reach it), as
opscrew interviewasks. OpsCrew connects, shows the server’s SSH host key fingerprint for you to check, and reads its size, memory, disk and architecture without changing anything; - your Portainer: name it (address, environment), confirm its certificate, and give the public address of its host, where the stack’s Caddy serves the app and the DNS records point.
- a cloud provider: OpsCrew offers its servers for the region and budget from your
interview, as
-
Prepare the new side.
- A cloud provider: the current server is renamed in OpsCrew’s infrastructure state, so both fit in it (nothing changes in your cloud), and the new server is created next to it, from an infrastructure plan you approve by its hash.
- Your own server: a host plan sets it up for the app, beside any other apps there, as for several apps on one server, and refuses what it refuses there. You approve it by its hash.
- Your Portainer: the environment is checked for the app’s stacks.
-
Deploy and check. The GitHub deploy secrets point to the new side (an approval), the app is deployed there and OpsCrew checks that it answers. Your visitors are still served by the old side, and the new side’s web server stays stopped until the switch.
- On a server, the deploy workflow runs. If the new side needs different deploy files (the
workflow for another SSH port or an image built for another processor, or the Compose and
web server files when the app comes from your Portainer, or Portainer’s stack template when
it goes there), OpsCrew opens one pull request with them and stops; merge it and run
npx opscrew retargetagain. - In Portainer, the app’s stacks are planned and approved by hash, as
opscrew provisiondoes, with the exact version the old side runs (the deploy branch is written with it, after your approval).
Until you finish or abort the move, a push to your branch deploys to the new side only, so hold changes until the switch.
- On a server, the deploy workflow runs. If the new side needs different deploy files (the
workflow for another SSH port or an image built for another processor, or the Compose and
web server files when the app comes from your Portainer, or Portainer’s stack template when
it goes there), OpsCrew opens one pull request with them and stops; merge it and run
-
The maintenance window copies the data (see below). It needs its own approval.
-
The switch. DNS switches to the new side, from a plan you approve by its hash, and OpsCrew checks the app through its domain. The old side stays, with its app stopped, until you finish the move.
Each plan is shown first and applied only once you approve its exact hash. Storing the new deploy secrets, pausing the scheduled check and starting the maintenance window each ask for your approval too.
The data
Section titled “The data”Every service on the app’s server that keeps data moves with it: PostgreSQL, MySQL, MariaDB, MongoDB, Valkey, Redis, Dragonfly, RabbitMQ, NATS, Kafka, Redpanda, Garage, OpenSearch, Elasticsearch, Meilisearch, Typesense, ClickHouse and Neo4j, plus the app’s own files (a SQLite database, uploads) and self-hosted monitoring on the app’s server.
- Most services: each one is stopped cleanly, and its stopped data (its Docker volumes) is copied to the same service on the new side, which was created but not started. Both sides compute a SHA-256 digest of the data, and the copy counts only when they match. Logins and passwords come along with the data. Both sides run the same image versions, which the plan pins.
- PostgreSQL is copied through its dump instead (
pg_dumpandpg_restore), which works across versions. The dump is checked on both sides too, and your release command runs on it when the app has one. - A service that doesn’t stop cleanly (Docker kills it when the 120 seconds to stop run out, or for lack of memory) isn’t copied: the window stops and starts the old app again.
The data streams through this computer and is never stored on it.
Copy or start empty
Section titled “Copy or start empty”For each service, OpsCrew asks whether to copy its data (the default) or start it empty on the new
side. In a script, name what starts empty with --empty <service>, once for each (app for the
app’s own files, monitoring for the monitoring stack). The summary says what starting empty loses:
| Service | Starting it empty loses |
|---|---|
| PostgreSQL, MySQL, MariaDB, ClickHouse | Every table and row |
| MongoDB | Every collection and document |
| Neo4j | Every node and relationship |
| Valkey, Redis, Dragonfly as a cache | Cached values, which the app fills again: usually fine |
| Valkey, Redis, Dragonfly as a queue | The jobs waiting in it: let the queue drain first, or copy it |
| RabbitMQ | The messages waiting in its queues |
| NATS, Kafka, Redpanda | The messages their streams and topics keep |
| Garage | Every file stored in it |
| OpenSearch, Elasticsearch, Meilisearch, Typesense | The search index, which the app must build again |
| The app’s own files | Its SQLite database and uploads |
| Monitoring | The metrics, logs and dashboards’ history collected so far |
The maintenance window
Section titled “The maintenance window”The summary shows the window’s expected length before anything changes:
| Between | About |
|---|---|
| Two servers (cloud or your own) | 2 minutes, plus 1 per 100 MB of data copied |
| A server and your Portainer | 2 minutes, plus 1 per 900 MB (about 15 MB/s through Portainer’s API) |
| With a bucket that moves | Add a minute per 2,000 objects for its last copy |
Data that starts empty doesn’t count. In the window, after your approval (which lists each step and each piece of data with its size):
- The app’s deploys and the scheduled check pause.
- The app, its workers and cron jobs stop on the old side, and PostgreSQL is dumped.
- The services stop cleanly on the old side, and the app and services stop on the new side.
- A bucket that moves gets its last copy.
- Each piece of data is copied and checked.
- The services start on the new side, PostgreSQL is restored, the release command runs, and the app starts and must answer its health check.
If anything fails, the window rolls back: the data written on the new side is emptied, the old side’s services and app start again, and nothing has switched. Your visitors are served by the old side, and the next run repeats the window. If the DNS switch fails, the same happens. Finishing or aborting the move resumes the scheduled check.
Without data to copy (none, or a database hosted elsewhere) and without a bucket that moves, there is no window: the old side serves until the switch.
Buckets
Section titled “Buckets”A bucket the app has at the provider being left moves to the new provider’s object storage, when the new target is the other cloud provider (Hetzner Object Storage or DigitalOcean Spaces) with object storage in the app’s region. See Object storage.
- Shown first: the summary shows the bucket’s size, its object count, the time to copy it and the egress. DigitalOcean Spaces includes 1,024 GiB of outbound traffic a month, then charges $0.01 per GiB. Hetzner Object Storage includes about 1 TB of traffic a month in its base price.
- How it moves: the new bucket is created from a plan you approve by its hash. The files are copied once before the window, from the new server, and the window copies only what changed since. The app on the new side uses the new bucket.
- The keys: OpsCrew needs a key for each bucket. A terminal asks for the new one once. In a
script,
OPSCREW_STORAGE_ACCESS_KEY_IDandOPSCREW_STORAGE_SECRET_ACCESS_KEYhold the key of the bucket being left, andOPSCREW_NEW_STORAGE_ACCESS_KEY_IDandOPSCREW_NEW_STORAGE_SECRET_ACCESS_KEYthe new bucket’s. A DigitalOcean key needs Full Access to create the bucket. The keys reach the copy only on its standard input, never a plan, log or audit entry. - To keep it where it is, pass
--keep-bucket. It keeps billing at the old provider. - At
--finish, the old bucket is deleted with its files, after you type the bucket’s own name (the app’s name doesn’t count for it). Until then it keeps billing.--abortdeletes the new bucket the same way, after its name.
A bucket stays where it is, and the summary says so, when the new target is your own server or your Portainer: it keeps billing at its provider, and the app on the new side uses it (OpsCrew asks for its key if this computer doesn’t have it). An R2 bucket always stays, since it’s Cloudflare’s. Garage is a service on the server, so its files are copied with the other data.
Your own server and your Portainer
Section titled “Your own server and your Portainer”OpsCrew never deletes, rebuilds or resets a server you own, and never removes your Portainer or its environment. On either side of a move:
- As the new side, the app is set up beside what is already there: a host plan on your server, stack plans in your Portainer, each approved by its hash.
- As the old side,
--finishremoves only the app, after you type its name:- from your server: its containers, its data, its site on the edge and its route, through a host plan you approve; the other apps on it keep running;
- from your Portainer: the app’s stacks and their volumes, and the deploy branch the stack was built from (OpsCrew asks first).
A move to your own server or Portainer refuses the same things opscrew provision refuses there
(see Own server and
Portainer). Your own server as the new side must be reachable from the
internet and serve the app directly (server.serve: proxy or dns-only). Portainer as the new
side serves the app with the stack’s Caddy and the updater (portainer.web: caddy,
portainer.redeploy: updater), and runs the image the app runs now, so its environment needs the
same processor architecture: move to a server of that architecture first, or run design and
provision to build for it.
A site on Cloudflare’s hosting moves to GitHub Pages, or the other way round, when design offers the new host for the app (GitHub Pages serves no server-rendered pages, for example).
npx opscrew retarget --to github-pages- Start: the summary shows the steps and the new host’s price.
- Deploy: the new host is set up without your domain, from plans you approve by their hash.
OpsCrew opens a pull request with the new deploy workflow; once you merge it, OpsCrew runs it
and checks the site at its
github.ioaddress or on the Worker, while the old host still serves your domain. To GitHub Pages, OpsCrew also creates GitHub’s domain verification record (--pages-verification <value>in a script; see Verify your domain). - The switch: a site has no data, so there is no maintenance window. One Cloudflare plan, which you approve by its hash and the app’s name, deletes the old host’s DNS records and creates the new one’s. The site is unreachable for the seconds between the two. On GitHub Pages, the certificate can take an hour or more to arrive: until then the site answers over HTTP only, and browsers that visited it on Cloudflare refuse HTTP.
--finishremoves the old Worker or GitHub Pages site, after the app’s name. GitHub’s verification record stays, so your domain stays verified.
Apps on several servers, and monitoring
Section titled “Apps on several servers, and monitoring”For an app with services on servers of their own, only the app server moves.
The extra servers stay where they are, with their data. The new app server joins the app’s
private network, and the old one leaves it at --finish (or the new one at
--abort). The services on the app server move with their data.
Self-hosted monitoring on the app server moves with its data, or starts empty
(--empty monitoring). On a server of its own it stays, and the new app server sends to it.
To keep a service at the old provider
Section titled “To keep a service at the old provider”There is no special mode for it: place the service on an extra server first, then move. With the
app still on the old provider, add a server there and give the service its server
(Moving a service of a live app), then run
npx opscrew retarget. The service stays on that server, and the new app server reaches it over
the private network.
Without DNS on Cloudflare
Section titled “Without DNS on Cloudflare”When OpsCrew doesn’t manage your DNS, it shows the records to change (or, without a domain, the new
side’s address). Your confirmation is the switch: confirm only once you made the change, since
after it the move can’t be aborted. OpsCrew then waits up to 5 minutes for your domain to point only
to the new side before checking the app; if it doesn’t yet, run npx opscrew retarget again later
to check.
With data to copy, the app on the old side stays stopped from the copy until your change has reached your visitors, so the downtime is the copy plus your DNS change. That is why a low TTL matters.
Finishing the move
Section titled “Finishing the move”Once the app runs on the new side and you are happy with it:
npx opscrew retarget --finishOpsCrew first checks that the app answers on the new side through its URL; otherwise it keeps the old side. Then it lists what it removes and asks you to type the app’s name:
| Old side | What --finish removes |
|---|---|
| A cloud server | The server, after a snapshot when the database or backups lived there (--snapshot or --no-snapshot to choose); the snapshot is billed until you delete it in the provider’s console |
| Your own server | Only the app and its data; never the server |
| Your Portainer | Only the app’s stacks, their volumes and the deploy branch; never Portainer or the environment |
| A site host | The old Worker or GitHub Pages site |
A bucket that moved is deleted next, after its own name (see Buckets). OpsCrew forgets
the old server’s host key and opens a pull request with the final infrastructure files: merge it,
so the repository describes the new deployment. The move is then done, and opscrew provision and
the other commands work as usual.
Aborting the move
Section titled “Aborting the move”Until DNS switches, you can go back:
npx opscrew retarget --abortOpsCrew starts the app on the old side again if it was stopped, removes the new side after you type the app’s name (a new cloud server is deleted; on your own server or Portainer only the app goes), deletes a new bucket after its own name, points the deploy secrets back to the old side and restores your plan. Once you have typed the name, the move can only be aborted, not continued.
After the switch, --abort refuses: finish the move, then move back with
npx opscrew retarget --to <the previous target>.
When a move stops
Section titled “When a move stops”A move remembers each step in opscrew/plan.yaml. If it stops, because a step failed, you declined
an approval or the terminal closed, run npx opscrew retarget again: it continues from the last
completed step. If it stopped during the maintenance window before the copy was complete, it first
starts the app on the old side again.
If it stops after the copy, before the switch, the app stays offline: its data is already on the new
side. Run npx opscrew retarget soon to continue, or npx opscrew retarget --abort to start the
old app again. Don’t start the app on the old side yourself, or what it saves won’t reach the new
side.
While a move is in progress, opscrew provision, design, dns, drift, destroy, rollback,
domain buy, domain connect and the add-on commands stop and say how to continue, finish or abort
it. opscrew status shows where the move stands and the next command.
In scripts
Section titled “In scripts”A run without a terminal stops at each plan, prints its hash and the command to continue with
--approve <hash>. Pass the flags of the first run again with each command. The other answers take
flags:
| Flag | What it does |
|---|---|
--option <id> |
The new cloud server to move to (the run lists the ids); without it, a script takes the recommended one |
--answers <file> |
Your own server’s (server.*) or your Portainer’s (portainer.*) answers, as opscrew interview --answers takes them |
--host-key <fingerprint> |
Your new own server’s SSH host key (SHA256:…), which you checked on the server |
--password-logins off|keep |
Your new own server, when other users log in with a password: turn password logins off anyway, or keep them |
--portainer-address <ipv4[,ipv6]> |
The public address of your Portainer host, for example 203.0.113.10 |
--portainer-cert <fingerprint> |
Portainer’s certificate fingerprint, trusted without a prompt |
--public-image |
Your Portainer pulls the app’s public image without a registry entry |
--empty <service> |
Start that service’s data empty (app, monitoring); repeat for each |
--keep-bucket |
Keep the bucket at the provider being left |
--pages-verification <value> |
GitHub’s domain verification value, for a site moving to GitHub Pages |
--approve <hash> |
Applies exactly the saved plan with this hash and continues |
--allow-downtime |
Allows the maintenance window |
--dns-switched |
You changed the DNS records (or the address) to the new side yourself |
--yes |
Approves storing the GitHub deploy secrets, pausing the scheduled check, the push and the pull request |
--confirm <name> |
With --finish or --abort, the app’s name instead of typing it; for a bucket’s deletion, the bucket’s name |
--engine <name> |
tofu or terraform |
For example, a move to your own server:
server.host: 203.0.113.20server.port: 22server.user: rootserver.key: ~/.ssh/id_ed25519server.reachable: trueserver.serve: proxynpx opscrew retarget --to own --answers own.yaml --host-key SHA256:... --empty cache --yes# stops at the host plan and prints the command to continue, with its hash:npx opscrew retarget --to own --answers own.yaml --host-key SHA256:... --empty cache --approve <hash> --allow-downtime --yes# ... one run for each plan, up to the switch; later, once you are happy:npx opscrew retarget --finish --approve <hash> --confirm example-appWhen a bucket is deleted at --finish or --abort, its plan stops on its own, and the printed
command carries --approve <its hash> --confirm <bucket name>.
Without DNS on Cloudflare, pass --allow-downtime --dns-switched together only when the records are
ready to change right after the copy: with --allow-downtime alone, the run stops after the copy,
with the app offline, until you change them and run npx opscrew retarget --dns-switched.
What can’t move yet
Section titled “What can’t move yet”opscrew retarget stops before any change, and says why, in these cases. Each can be done by hand.
Where that means removing the app, npx opscrew destroy deletes its data on a server it created (it
offers a snapshot first), so back up your data first and restore it on the new target (see
Backups).
- This computer (your own computer) on either side: it runs a copy for trying
changes, not a deployment. Remove that environment with
npx opscrew env remove <name>and add one for the target you want withnpx opscrew env add <name>. - A preview environment: its previews come and go with pull requests. Remove it
(
npx opscrew destroy --env <name>, thennpx opscrew env remove <name>) and add it again withnpx opscrew env add <name> --previews --target <provider>. - An environment whose server runs another environment’s previews, while they run: remove the
previews first with
npx opscrew destroy --env <previews environment>(it leaves the server as it is), move, then set the previews up again. - A Cloudflare Tunnel on either side: an own server reached through one, Portainer’s tunnel
modes, or Portainer or Beszel’s hub on a subdomain through one. A move switches between address
records only. First serve the app on a public address (
server.serve: proxyordns-onlyfor your server;portainer.web: caddywithportainer.redeploy: updaterfor Portainer) withnpx opscrew interview,designandprovision, then move. Turn Portainer or Beszel on a subdomain off first (npx opscrew portainer disable,npx opscrew addon disable beszel), and on again afterwards. To use a tunnel on the new side, move first, then change its answers and rundesignandprovision. - Portainer behind your own proxy, with Cloudflare DNS: serve the app with the stack’s Caddy
first (
portainer.web: caddy, theninterview,designandprovision), then move. - An Edge Agent environment in Portainer: use an environment Portainer reaches through its
Docker socket or the Portainer Agent, or a server. Otherwise remove the app with
destroyand set it up there withinterview,designandprovision. - Two Portainer environments, in one Portainer or two (the app has one deploy branch and one
Portainer token): move the app to a server first, then into the other environment; or remove it
with
destroyand set it up there withprovision. - A site to a server, or a server to a site host: the app’s whole design changes. Run
npx opscrew destroy, theninterview,designandprovisionfor the new target. - A move within one cloud provider: for another size, see
Advice on size, cost and upkeep. For another location, back up
the data, run
destroy, theninterviewwith the new location,designandprovision, and restore the data; or move to another target and back.