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

WhatWhere it livesHow it moves
Every page, user, comment, permissionPostgresthe database dump
Images and attachmentsthe app_storage volumethe storage archive
Secrets and configuration.envyou copy the file
License key and its fingerprintinside the databasethe dump carries it; no reissue needed
Sessions (who is signed in)rows in the sessions tablethe dump, on its own. Nobody is signed out
Redisthe redis_data volumeit does not. It is a job queue and rebuilds itself

Two of those rows deserve a sentence each, because getting them wrong is silent:

  • .env is 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 the sessions table, nothing signs it, and it survives on the dump alone. What is in .env and does matter for a move:

    • COMPOSE_PROJECT_NAME decides 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_SECRET signs 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_PASSWORD is 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 .env sets it rather than having to match it. (Restoring into an existing volume, as in Backup and Restore, is where a wrong DB_PASSWORD locks 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/ and imports/ under the storage root itself, on first use. tar running 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 -R after the extraction settles it. (In Backup and Restore the same step uses plain alpine, 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 saysIt means
core migrations this image does not knowthe backup came from a newer version. Pin APP_IMAGE to the old server’s version and pull again
no applied migrations at allthe dump was not restored, or COMPOSE_PROJECT_NAME changed and you are looking at a fresh empty volume
storage has not been restoredthe tar step was skipped; every image would be a broken link
fingerprint does not matchthis dump is not the one the manifest stamps
the backup was taken with release … and this image is … , which is OLDERsame 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:

  1. Open a page that has an image on it, and confirm the image renders.
  2. 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.