How to migrate a web app from a VPS to xhostd
Migrate a web app from a VPS to xhostd: pick a template, migrate Postgres with pg_dump, move files, and cut over your domain with a short write pause.
To migrate a web app from a VPS to xhostd, deploy the code to xhostd first, copy
the Postgres database with pg_dump and pg_restore, move uploaded files to
object storage, and then point your domain at the new copy. The old server keeps
serving visitors until the last step, which makes the move a near zero-downtime
migration.
xhostd is managed application hosting: it runs the process, the certificate, the database, and the backups that you operate yourself on a VPS. If you have not decided whether to move, read VPS vs. managed application hosting first.
What changes when you move to managed hosting
Your code and your data format stay the same. Postgres is Postgres, and a dump from your server loads into a channel database. These parts change:
| On the VPS | On xhostd |
|---|---|
| A process manager keeps the app running | The platform runs and restarts the container |
| You install and renew certificates | The platform issues and renews them |
| You configure the database and its backups | Each channel has a database with nightly and pre-deploy snapshots |
| A directory holds uploads | S3-compatible storage holds uploads |
A .env file holds settings |
Platform environment values, set with set_env |
| You copy a build to the server | You git push, then deploy |
You can do the move yourself or ask your AI coding agent, such as Claude Code or Codex, to do the code and deploy steps through MCP tools.
Step 1: inventory the old server
Write down these facts before you change anything:
- the runtime and version (Node.js, Python, or something else);
- the command that starts the app, and the port it listens on;
- every environment variable it reads;
- the Postgres version, the database size, and the extensions in use;
- where uploaded files live;
- every scheduled job and background process;
- the DNS records for your domain, and their time to live (TTL).
Step 2: pick a template
- A Node.js or Python service fits the
apptemplate:install.shbuilds it andlaunch.shstarts it. - Any other runtime, or an app that already has a
Dockerfile, fits thedockertemplate. - A site of plain files fits the
statictemplate.
Two changes are common. The app must listen on the port in the XHOSTD_HTTP_PORT
environment variable, on 0.0.0.0. And GET / must return a 2xx or 3xx
response, because the deploy health check probes it. The
Node.js,
Python, and
Docker recipes show the exact
files. A long-running process that is not a web server fits the
background worker recipe.
xhostd has no separate scheduler. Move a recurring job into a background worker that sleeps between runs, as the worker recipe shows.
Step 3: connect the database
Read the connection string from DATABASE_URL, which xhostd sets. Do not
hard-code credentials. A Python project that uses SQLAlchemy with psycopg 3 must
rewrite the postgresql:// scheme to postgresql+psycopg://, or the app fails
with No module named 'psycopg2'.
Run schema migrations in the start command, not at build time, because the build
has no DATABASE_URL. Use DATABASE_URL_DIRECT for migrations, because it
reaches the database server without the transaction pooler. The
Postgres recipe explains both
variables.
Step 4: deploy the code first
Create the app, push the code, and deploy. The Claude Code deploy article shows the calls, and they are the same for other agents. Deploy before you load data. This proves the build and the health check independently of your data.
To rehearse the whole move, deploy to a preview channel first. See Preview and staging environments.
Step 5: migrate Postgres
-
Dump the old database in custom format:
pg_dump --format=custom --file=app.dump "$OLD_DATABASE_URL" -
Turn on external database access on the project's Database page in the console, a tab of the project's Data & recovery section. In the External database access card, select Enable external access. Only a person can do this in the console. An agent receives a
403 protected_actionerror unless you first turn on agent access for it. Then copy the connection URL. -
Restore into the channel database, skipping owner and access commands that name roles your channel does not have:
pg_restore --no-acl --no-owner -d "<connection URL>" app.dump -
Verify the data. Count rows in your largest tables on both sides, and open the new channel URL.
Two cautions. Change sslmode=require in the URL to sslmode=verify-full sslrootcert=system where your client supports it, because require encrypts
but does not verify the gateway. And check your extensions: xhostd marks PostGIS
as trusted, and you install it in a migration, but do not assume every other
extension is available. Check before the day of the move.
For a large database, restore twice. A rehearsal restore tells you how long the real one takes.
Step 6: move the files
Copy uploaded files into the channel's object storage. The S3_* credentials are
already in the environment, and any S3 client library works. Change the app to
read and write through that storage instead of a local directory. The
file upload recipe has a working
example. A container's writable layer is not durable storage, so do not keep
uploads there.
Step 7: set environment variables
Move every setting from your old .env file with set_env, except the database
settings, which xhostd already provides. Keep secrets out of your repository.
Step 8: plan a zero-downtime cutover
Visitors keep reaching the old server until DNS changes, so the site stays up throughout. Only writes need a short pause, to keep the two databases identical.
- A day ahead, lower the TTL on your existing DNS records.
- Attach the domain.
add_custom_domainreturns a TXT record and a routing record. A subdomain uses a CNAME. A bare domain uses an A record, because most registrars do not allow a CNAME at the apex. - Create the records at your registrar and call
verify_custom_domain. Retry if DNS has not propagated. - Pause writes on the old server, then run a final dump and restore.
- Switch DNS to xhostd. The certificate is issued on the first HTTPS request, which can take a few extra seconds.
The custom domains page has the details and error codes. Google sign-in works on verified custom domains with no extra setup.
Step 9: keep the old server briefly
Watch the runtime log and the error rate after the switch. Keep the old server for at least a week as your rollback: repoint DNS and you are back. Shut it down only after a nightly snapshot exists on xhostd and you have downloaded a dump of your own. See Postgres backups and recovery for what xhostd keeps and for how long.
You can also leave again. You can download a pg_dump of any channel database
from the console, so your data is never locked in. To estimate cost, read the
pricing page. To start from scratch instead of migrating, use
Getting started.