All documentation
Getting started

Migrating a server

Read a Laravel Forge or plain Ubuntu machine, recreate its sites, databases, cron entries and daemons on a fresh Provisore server, and move the data across yourself.

You move the data yourself

A migration is not one button. Provisore recreates the records for your sites, databases, cron entries and daemons on a new server. The data, the uploaded files, and the DNS change are yours to do afterwards, in that order. The second half is the long one.

What it does

It reads an existing machine and recreates its sites, databases, database users, scheduled commands, and long-running processes on an active Provisore server: one you already have, or one the wizard creates for you. Start from Import from Forge in the + menu, or Migrations in ⌘K. A plain Ubuntu machine starts there too.

The old machine is only read. Provisore never provisions it, never deploys to it, and never counts it against your plan. It keeps serving its sites for as long as DNS points at it, so you can stop halfway through for a week and lose nothing.

Connecting the machine

There are two kinds of source. A Laravel Forge source is read through Forge's API for the facts a server cannot tell you (which repository a site is built from, which branch, its deploy script, its aliases) and over SSH for everything else. A machine connected by hand is read over SSH only, so those fields come back empty and you fill them in before the first deploy.

Connecting is a one-liner you run as root on the old machine. It adds three things:

Everything the bootstrap puts on the old machine
provisore-migrate                     # a login account, and its home directory
/etc/sudoers.d/90-provisore-migrate   # one sudoers file
~provisore-migrate/.ssh/authorized_keys   # one key, in that account's own file

No package is installed, no service is restarted, nothing is written under /opt, sshd is not reconfigured, and nothing goes into root's authorized_keys. Removing those three things removes everything.

The account has passwordless sudo, because the scan reads root-owned configuration across the whole machine. The access expires by itself after 14 days. You can end it sooner from the last step of the migration, which removes the three items above and then reconnects to confirm they are gone.

The plan

The scan produces a table of everything it found, and you tick the rows you want. Choose the new server, review, confirm. Rows are created in dependency order: databases, then database users, then sites, then daemons and cron entries. A user is recorded against a database, and a worker's account depends on the site it belongs to.

An apply is not a transaction. Each row ends as created, failed with the reason, blocked naming the row it was waiting for, or skipped. Fix the failed row and apply again. Nothing that succeeded is undone.

What is left out

The review screen lists what is yours. Things that belong to the operating system or to the old control plane are kept off the new server, and each one is reported with the reason in a separate list of exclusions.

  • The distribution's own cron entries. Matched on both the file name and what the line runs, because /etc/cron.d/php is Debian's session cleanup on every box and also an ordinary name for a file you wrote yourself. Your new server's packages bring these with them. Copying them would run each job twice.
  • Certificate renewal. The certbot package's entry, and the old control plane's per-certificate letsencrypt-renew-domain-* entries. Provisore issues and renews its own certificate for every site it serves. Copying these would put two renewal mechanisms on one server.
  • Forge's default forge database and role. Only when all three of these hold: the source is a Forge account, nothing on the machine holds a grant on it except its own forge account, and no site being migrated is named for it. The scan reads configuration, not application data, so it cannot tell whether the database is empty.

Two rules protect the jobs people schedule themselves. A user crontab is never excluded, for any reason. And /etc/crontab is judged line by line rather than as a file, because Forge writes every job it manages into it, including the Laravel scheduler. Only its run-parts dispatch lines are treated as the distribution's.

Applying a plan copies no data

Applying creates records: the site rows, the database rows, the users, the cron entries, the daemons. It copies nothing.

  • Databases are created empty. Every row stays on the old server until you dump it and load it here yourself. There is no button for this.
  • No file is copied. Everything under each site's storage/, plus whatever else the application has written to disk: avatars, invoices, generated PDFs, imports.
  • No certificate and no private key. A private key is never taken off a machine. The new server issues its own.
  • No password. The old machine's passwords cannot be read back. New database accounts get new generated passwords, revealable once.
  • Nothing in memory. Redis and Memcached contents, queues, and sessions stay where they are. Jobs queued on the old machine will run there.
  • Nothing installed by hand. Extensions, system packages, tuned configuration, a binary somebody dropped in /usr/local/bin. The scan reads sites, databases, scheduled commands, and processes, and nothing else.
  • DNS. Provisore does not touch your records. Pointing the domain at the new server is your step, and it is the step that moves your traffic.

Do the database dump last, or do it twice. Anything written to the old server after your dump stays on the old server.

Database engine versions

Data does not move backwards across an engine's major version. Forge installs PostgreSQL from the PostgreSQL project's own archive, which runs ahead of Ubuntu's, so the old machine is often the newer one. On Provisore you choose the PostgreSQL major when you install the engine: 18, 17, 16, 15, 14, with 18 as the default. When the server’s Ubuntu release does not carry that major, Provisore adds the PostgreSQL project’s own apt repository, with its signing key checked against a pinned fingerprint. Choose a major at least as new as the old machine’s.

The scan compares both machines and reports a mismatch on the review screen, before you commit to a plan. It warns and does not block. Applying moves no data, the mismatch is fixed on the new server without changing the plan, and there is more than one way through it. An engine whose version could not be read is reported as unknown, never as compatible.

This is what the refusal looks like when it is not caught first:

pg_restore: error: unsupported version (1.16) in file header

That is the file header, so nothing partial has been written. A plain-SQL dump gets further and then needs one line removed by hand: SET transaction_timeout = 0;, a setting PostgreSQL 17 introduced and 16 rejects. That only works for a plain schema. A dump using a newer type, newer syntax, or an extension the older server does not have will not restore, and there is no line to delete.

MySQL and MariaDB are compared on two version components rather than one, because 8.0 and 8.4 are separate release series with no supported downgrade between them, and so are MariaDB 10.11 and 11.8. Redis is compared on its major version, which is what versions the RDB file. Memcached is not compared, because it persists nothing.

After a restore, check it as the site's own role

A dump carries no privileges, and whatever ran the restore owns what it created. Restore as postgres over the Unix socket and everything looks fine to postgres. Check it the way your application will connect: as the site's own role, over TCP, with the password.

psql -h 127.0.0.1 -U shop_example_com -d shop_example_com -c '\dt'

A missing grant does not report itself as a missing grant. PostgreSQL answers relation "orders" does not exist, which reads like a restore that did not load the data. If you see it, grant the site's role usage on the schema and privileges on the tables and sequences, then look again.

Cutting over

The order matters, and only one step cannot be undone:

  1. Deploy each site. The records exist, but no code does yet. A deploy clones the repository, builds the release, and makes Nginx serve it. A site created without a repository needs one under Settings, Source, first.
  2. Move the database contents and the uploaded files. Both are yours to do.
  3. Point DNS at the new server. This moves your traffic, and it is the first step you cannot take back. Everything above it can be done at your own pace, because the old machine is still serving every request.
  4. Issue certificates. With HTTP-01, the default, this can only happen once DNS resolves to the new server, so expect a short window where the new server has no certificate. A site set to DNS-01 through a connected Cloudflare token can issue before you switch DNS, which closes that window.
  5. Check it works. Load the sites, sign in, submit something, and watch that the scheduled commands fire and the processes stay up. A worker with a wrong path looks like a running worker until you check its log.
  6. End Provisore's access to the old machine, once you are sure you will not need to read it again. Provisore destroys its own key either way, so after this it cannot go back.
  7. Decommission the old machine yourself. Cancel it where you pay for it, once nothing points at it.
Nothing on the old machine changes

Not during the scan, not during the apply, not during the cutover. The only thing Provisore writes to a migration source is the reader account it adds at the start and removes at the end.