This guide covers a first transfer into a new empty installation on one VPS using Local storage. Complete Provision and the first Deploy before importing. Transfer one consistent set: database, image snapshot and file snapshot. Docker images do not contain that data.
A full restore replaces the database. Do not use it over existing target users or records you need to retain. Preserving target accounts requires an application-specific business-data-only import. Excluding source auth rows does not preserve target users: pg_restore --clean still recreates the tables.
For production backups, use controller backup/restore, which coordinates database and native snapshots. The manual dev → new VPS procedure below assumes Local storage. S3 requires a separately prepared access/transfer procedure.
The commands below create this package; do not write the JSON files manually:
TRANSFER_DIR/
database.dump
media/ native image snapshot
media-result.json export result containing storeIdentity
files/ native document snapshot
files-result.json export result containing storeIdentity
Omit folders/results for attachment types absent from the model.
1. Match versions and freeze writers
Local schema and migration inventory must match the deployed release. With Source CI, temporarily check out the exact production commit locally instead of assuming main has the same SHA. Update the server's seed checkout explicitly too; CI does not switch its branch.
Stop make dev, cleanup and custom writers to the source database/stores until export finishes. Do not create target accounts or content before import.
Replace PROJECT_NAME, YOUR_SERVER_HOST and container names below with your own values. Find the dev PostgreSQL container through docker ps; use its name as DEV_POSTGRES. Keep the same Bash session for the following local blocks. set -e stops it on failure; do not continue with later commands until the error is resolved.
2. Configure local access
bash
This opens Bash. Run in that session:
set -e
cd /path/to/PROJECT_NAME
export APP_PROFILE=local
unset DATABASE_URL DATABASE_URL_FILE
make migrate
Run the connection-string prompt separately, enter the private DATABASE_URL and press Enter:
read -r -s -p 'Local DATABASE_URL: ' DATABASE_URL
After entering it, continue:
printf '\n'
export DATABASE_URL
export MEDIA_ROOT=/absolute/path/to/local/media
export FILES_ROOT=/absolute/path/to/local/files
ENV_NAME=local
DEV_POSTGRES=YOUR_DEV_POSTGRES_CONTAINER
TRANSFER_DIR="$HOME/project-transfer-$(date +%Y%m%d-%H%M%S)"
umask 077
mkdir -m 700 "$TRANSFER_DIR"
Make migrate applies missing local migrations before the snapshot. Stop on a checksum error and reconcile code/database versions; do not edit the ledger manually.
Use DATABASE_URL from private dev configuration, pointing to the same database as DEV_POSTGRES. The read prompt avoids placing the URL in command history. Set the actual absolute local MEDIA_ROOT and FILES_ROOT. The Go commands do not automatically load .env. The --env local argument labels the snapshot; it does not select a configuration file.
If the model has no images or files, its corresponding snapshot command may be absent: omit that block and its target steps. TRANSFER_DIR is a new private folder outside Git.
3. Export images and files
For images:
mkdir -m 700 "$TRANSFER_DIR/media"
(
cd backend
go run ./cmd/media-snapshot --mode export \
--env "$ENV_NAME" --stage "$TRANSFER_DIR/media" --writers-frozen \
> "$TRANSFER_DIR/media-result.json"
)
For native files, including PDF and presentations:
mkdir -m 700 "$TRANSFER_DIR/files"
(
cd backend
go run ./cmd/files-snapshot --mode export \
--env "$ENV_NAME" --stage "$TRANSFER_DIR/files" --writers-frozen \
> "$TRANSFER_DIR/files-result.json"
)
Stage directories must be empty. Snapshot commands match objects to database inventory and record their manifests/hashes. Keep media-result.json and files-result.json too: they contain the source storeIdentity required by restore; snapshot.json does not contain it.
Export snapshots before pg_dump so the first binding of an empty store is included in the database dump. After failure, inspect the error and use a new staging directory instead of reusing a partial export.
4. Dump the database and send the complete package
docker exec "$DEV_POSTGRES" sh -c \
'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc --no-owner --no-acl' \
> "$TRANSFER_DIR/database.dump"
scp -r "$TRANSFER_DIR" deploy@YOUR_SERVER_HOST:/home/deploy/
This dump includes local accounts and sessions, so a full transfer preserves their sign-in credentials. To create new server accounts instead, add --exclude-table-data='admingen_auth.*' to pg_dump and bootstrap a new superadministrator after restore. This still requires an empty target.
Do not send database.dump alone: PostgreSQL does not contain file bytes. After successful export you can resume local development; the package remains a consistent snapshot.
5. Stop the target and prepare restore
Run the remaining steps as the deployment user in Bash. Verify that the target is empty. Freeze CI deployments, cleanup and all external writers during restore.
ENV_NAME is the profile name. Get RUNTIME_ROOT and Local storage roots from Deployment → Environments → Preview effective JSON. TRANSFER_DIR is the folder just sent by scp, including its timestamp. Find COMPOSE_PROJECT from the container label for the intended application:
docker ps -a --format 'table {{.Names}}\t{{.Label "com.docker.compose.project"}}'
bash
In the opened Bash session:
set -e
cd "$HOME/projects/PROJECT_NAME"
ENV_NAME=production
RUNTIME_ROOT=/srv/admingen/PROJECT_NAME/production
COMPOSE_PROJECT=YOUR_COMPOSE_PROJECT
TRANSFER_DIR="$HOME/project-transfer-YYYYMMDD-HHMMSS"
MEDIA_ROOT=/absolute/production/media
FILES_ROOT=/absolute/production/files
bash ./deploy/control down --env "$ENV_NAME" --confirm "$ENV_NAME"
BACKEND_IMAGE=$(jq -er .backend "$RUNTIME_ROOT/state/current.json")
PG=$(docker ps -a \
--filter "label=com.docker.compose.project=$COMPOSE_PROJECT" \
--filter label=com.docker.compose.service=postgres --format '{{.ID}}')
docker start "$PG"
docker exec "$PG" pg_isready -U postgres -d app
Use the actual runtime/storage paths and Compose project from your installation. Generated server installations use database app and user postgres. PG must contain exactly one container ID; stop if none or several are found. Continue only after pg_isready reports accepting connections.
Backend/frontend remain stopped; start only the existing PostgreSQL container. Do not create another database or run Provision again for transfer.
6. Verify empty stores
This function runs native snapshot commands from the current backend image using its production database secret and CA. Set the actual Local roots from your profile. Secrets do not enter the transfer package.
snapshot() {
local kind="$1" storage="$2" variable
shift 2
case "$kind" in
media) variable=MEDIA_ROOT ;;
files) variable=FILES_ROOT ;;
*) return 1 ;;
esac
docker run --rm --platform linux/amd64 \
--network "$COMPOSE_PROJECT-private" \
--mount "type=bind,src=$RUNTIME_ROOT/secrets/database_url,dst=/run/secrets/database_url,readonly" \
--mount "type=bind,src=$RUNTIME_ROOT/pki/ca.crt,dst=/run/secrets/ca.crt,readonly" \
--mount "type=bind,src=$storage,dst=$storage" \
--mount "type=bind,src=$TRANSFER_DIR/$kind,dst=/snapshot,readonly" \
-e APP_PROFILE=production -e DATABASE_URL_FILE=/run/secrets/database_url \
-e "$variable=$storage" \
--entrypoint "$kind-snapshot" "$BACKEND_IMAGE" "$@"
}
snapshot media "$MEDIA_ROOT" --mode verify-empty --env "$ENV_NAME" --writers-frozen
snapshot files "$FILES_ROOT" --mode verify-empty --env "$ENV_NAME" --writers-frozen
Verify-empty checks physical storage and stops if objects already exist. Do not delete data just to continue. Verify-empty does not accept stage/restore arguments.
7. Restore database and native snapshots
Continue only after both applicable stores pass verification:
docker exec -i "$PG" pg_restore -U postgres -d app \
--clean --if-exists --exit-on-error --single-transaction --no-owner --no-acl \
< "$TRANSFER_DIR/database.dump"
if [ -d "$TRANSFER_DIR/media" ]; then
sudo chown -R 10001:10001 "$TRANSFER_DIR/media"
fi
if [ -d "$TRANSFER_DIR/files" ]; then
sudo chown -R 10001:10001 "$TRANSFER_DIR/files"
fi
snapshot media "$MEDIA_ROOT" --mode restore \
--env "$ENV_NAME" --stage /snapshot --writers-frozen \
--confirm-restore "$ENV_NAME" \
--expected-identity "$(jq -er .storeIdentity "$TRANSFER_DIR/media-result.json")"
snapshot files "$FILES_ROOT" --mode restore \
--env "$ENV_NAME" --stage /snapshot --writers-frozen \
--confirm-restore "$ENV_NAME" \
--expected-identity "$(jq -er .storeIdentity "$TRANSFER_DIR/files-result.json")"
Chown changes only the transferred snapshot folders so the standard backend UID can read them. Do not apply it to unrelated paths. A server operator can perform this step if the deployment user has no sudo.
Restore verifies database inventory, source identity and object manifests, transfers files and registers the new storage binding itself. Manual store_identity SQL and raw media-directory copying are unnecessary. On failure leave the application stopped and inspect the error; do not blindly repeat the complete restore.
8. Start and check
For Source/on-host management, start the recorded release:
bash ./deploy/control up --env "$ENV_NAME" --confirm "$ENV_NAME"
bash ./deploy/control status --env "$ENV_NAME" --json
For remotely managed Archive/Registry environments, use their ordinary controller access rather than SSH-to-self without credentials. Source CI can also redeploy the same commit after import succeeds.
Check records, translations, image sizes, file downloads and sign-in. If auth data was excluded, bootstrap a private superadministrator first; create any public demo account separately with appropriate permissions. Routine later deploys preserve the imported data; do not repeat the initial transfer.