Self-Hosting
Moving to a New Server
Moving to a New Server
One instance, one move: everything on the old server, transferred and restored on the new one, verified before it serves a single request. This chapter is the whole procedure in order. It uses the same building blocks as Backup and Restore, plus a verification gate that refuses a restore the new server cannot run.
What moves, and how
| What | Where it lives | How it moves |
|---|---|---|
| Every page, user, comment, permission | Postgres | the database dump |
| Images and attachments | the app_storage volume | the storage archive |
| Secrets and configuration | .env | you copy the file |
| License key and its fingerprint | inside the database | the dump carries it; no reissue needed |
| Sessions (who is signed in) | rows in the sessions table | the dump, on its own. Nobody is signed out |
| Redis | the redis_data volume | it does not. It is a job queue and rebuilds itself |
Two of those rows deserve a sentence each, because getting them wrong is silent:
-
.envis half the move, though not for the reason people expect. Copy the file as it is, over ssh. It is not what keeps anyone signed in: a session is an opaque random token in thesessionstable, nothing signs it, and it survives on the dump alone. What is in.envand does matter for a move:COMPOSE_PROJECT_NAMEdecides which volumes every command in this chapter addresses. Change it and you spend the move looking at empty volumes that come up healthy.APP_URL, and the cookie settings that follow from it. These have to match the address people actually type.JWT_SECRETsigns the short-lived two-factor and set-password challenges, and keys the proof appended to the image URLs inside a password-protected share link. Generate a new one and any half-finished sign-in dies (people simply try again) and already-published share links stop loading their images. Sessions are untouched either way.INSTANCE_ADMIN_EMAILS, your lock-out recovery lever, plus the LDAP, SMTP, licence and feedback settings.
DB_PASSWORDis the one thing that is less dangerous here than in an ordinary restore. Step 3 below creates a brand new Postgres volume, and the password is applied when that volume is initialized, so the copied.envsets it rather than having to match it. (Restoring into an existing volume, as in Backup and Restore, is where a wrongDB_PASSWORDlocks you out — the volume was initialized long ago and does not change.) -
The license needs no portal step. The fingerprint your key is bound to is derived from an id stored in the database, so it travels inside the dump. The reissue flow in Licensing exists for fresh installs taking over a license, which is not what this chapter does.
Before you start
Install the same version on the new server, or a newer one. Migrations only run forward, so an older image cannot run a newer backup, and the verification step in step 5 refuses that in two ways:
- by migration, always: a backup naming a migration this image has never heard of is refused by name, whether or not either side reports a version;
- by release, when both sides know theirs: if the backup was taken on a newer release than the image about to run it, and the two carry exactly the same migrations, that is refused too. Same migrations is precisely the case nothing else here can see — a newer release can read the same schema differently.
An image built outside our release pipeline calls itself dev rather than a version number, and two of those compare as the same release. The findings say so in as many words and refuse nothing on that basis; the migration check still applies either way.
Find what the old server runs in Admin, then Instance, then About, or with:
docker compose images app
To run exactly that version on the new server, pin it in the .env you are about to copy (see Pinning a version):
APP_IMAGE=registry.thenexus.dev/saas/docu.nexus:v1.2.3
Step 1: back up the old server
From the install directory. Stop the app first: the dump, the storage archive and the manifest must describe the same instant, and a running app writes to all three.
cd docu.nexus
docker compose stop app
mkdir -p backups
docker compose run --rm -T app relocation manifest > backups/docunexus-manifest.json
docker compose exec -T postgres \
pg_dump -U docunexus --no-owner --no-acl docunexus \
| gzip > backups/docunexus-db.sql.gz
The uploads live in a named volume, and the name is prefixed with your compose project. Read the real name off Docker rather than typing it, the same way Backup and Restore does:
STORAGE_VOL=$(docker volume ls --format '{{.Name}}' | grep '_app_storage$')
echo "$STORAGE_VOL" # expect exactly one name, e.g. docunexus_app_storage
docker run --rm \
-v "$STORAGE_VOL":/data:ro \
-v "$PWD/backups":/out \
alpine tar czf /out/docunexus-storage.tar.gz -C /data .
ls -lh backups/ # all three files, and the archive is not a few dozen bytes
Leave the old stack stopped but intact. It is your rollback until the new server has proven itself.
Step 2: transfer
Four files: the manifest, the dump, the storage archive, and .env.
scp backups/docunexus-manifest.json backups/docunexus-db.sql.gz \
backups/docunexus-storage.tar.gz .env \
you@new-server:
.env holds your secrets. Move it over ssh, not through anything shared, and chmod 600 it on arrival.
Step 3: prepare the new server
You need Docker and the two files every install directory holds: the stack definition, downloaded fresh, and your .env, copied from the old server.
mkdir docu.nexus && cd docu.nexus
curl -fsSLO https://docu.nexus/docker-compose.yml
mv ~/.env .
chmod 600 .env
mkdir -p backups
mv ~/docunexus-manifest.json ~/docunexus-db.sql.gz ~/docunexus-storage.tar.gz backups/
docker compose pull
docker compose create
docker compose create builds the containers and the empty volumes without starting anything. Because your .env came across whole, the fresh Postgres volume is initialized with your existing DB_PASSWORD, and COMPOSE_PROJECT_NAME scopes the volumes to the same project the old server used — so the names match, and every command from here on lets Compose find them rather than naming them.
Step 4: restore
Database first. Start only Postgres, wait until docker compose ps shows it healthy, then feed it the dump:
docker compose up -d postgres
docker compose ps
gunzip -c backups/docunexus-db.sql.gz \
| docker compose exec -T postgres psql -U docunexus -d docunexus --quiet
Then the uploads, into the still-empty storage volume. Do this through the app’s own image, not through a scratch alpine:
docker compose run --rm --no-deps --user root \
--entrypoint sh -v "$PWD/backups":/in app \
-c 'tar xzf /in/docunexus-storage.tar.gz -C /app/storage \
&& chown -R docunexus:docunexus /app/storage'
Two reasons:
- Compose resolves the volume for you. No volume name is typed here, so the wrong-name trap from step 1 cannot be sprung on the server where it would be worst.
- The app writes into that directory as a non-root user. It creates
uploads/,files/,thumbs/andimports/under the storage root itself, on first use.tarrunning as root restores whatever numeric owner the old server’s files had, and a volume that no container has ever written to belongs to root — either of which can leave the first upload after the move failing with a permission error, on an instance that started cleanly and passed verification.chown -Rafter the extraction settles it. (In Backup and Restore the same step uses plainalpine, because there the app has been writing to that volume for months and its root already belongs to the app user. Here it has never been touched.)
Step 5: verify, then start
This is the gate. It compares the restored database and storage against the manifest, and this image against both:
docker compose run --rm -T app relocation verify < backups/docunexus-manifest.json
VERIFY PASSED means the migration state, the row counts of every table, the storage totals, a sample of attachment files, and the instance fingerprint all match the manifest, and that this image can run them. Start the stack:
docker compose up -d
docker compose logs -f app
VERIFY REFUSED means stop and read the findings. Each one says what to do; the common ones:
| Finding says | It means |
|---|---|
| core migrations this image does not know | the backup came from a newer version. Pin APP_IMAGE to the old server’s version and pull again |
| no applied migrations at all | the dump was not restored, or COMPOSE_PROJECT_NAME changed and you are looking at a fresh empty volume |
| storage has not been restored | the tar step was skipped; every image would be a broken link |
| fingerprint does not match | this dump is not the one the manifest stamps |
| the backup was taken with release … and this image is … , which is OLDER | same cause as the first row, caught by the version stamp instead of by a migration name. Pin APP_IMAGE to at least the backup’s release |
Step 6: cut over
Point DNS (or your reverse proxy) at the new server. If the instance keeps its domain, change nothing else: APP_URL still matches, the cookies people are already holding still match it, and everyone’s session carries on where it was — the sessions rows came over in the dump, and nothing outside the database is needed to honour them.
If the address changes, update APP_URL in .env and check the cookie settings against the Configuration Reference before docker compose up -d again.
Then check with your own eyes, signed in on the new server:
- Open a page that has an image on it, and confirm the image renders.
- Open Admin, then Licensing, and confirm the key shows valid with the same fingerprint as before.
Step 7: decommission the old server
Not on cutover day. Keep the old stack stopped but intact through at least one backup cycle on the new server. When you are sure:
docker compose down
That keeps the old volumes on disk as a last resort; remove them with your own retention judgment, not as part of the move.