# VPS Migration Plan — nerdbox.com → New VPS

**Prepared:** 2026-04-10  
**Scope:** Full lift-and-shift of all services to a new VPS with zero data loss and minimal downtime  
**Estimated downtime window:** 15–30 minutes (DNS cutover only)

---

## Overview

All services currently run on a single VPS (96 GB disk, ~80% full). Migration target is a
fresh VPS with ≥200 GB disk. Every service is containerized and backed by nightly S3 backups,
so the migration strategy is: **provision new host → restore from backup → verify → cut DNS**.

### Services being migrated

| Domain | Service | Stack |
|--------|---------|-------|
| `classicslens.com` | ClassicsLens reading app | lector compose |
| `n8n.nerdbox.com` | n8n automation | n8n compose |
| `openclaw.nerdbox.com` | OpenClaw agent gateway | openclaw compose |
| `conductor.nerdbox.com` | Conductor dashboard | conductor-dashboard compose |
| `files.nerdbox.com` | File server (diagrams) | openclaw compose |
| *(internal)* | PostgREST, bridge, morpheus | n8n / openclaw compose |

### Host services also being migrated

- `ollama` (systemd) — local LLM inference
- `openclaw-executor` (systemd) — HMAC shell runner

---

## What You Need to Do vs. What Can Be Scripted

Sections marked **[YOU]** require your direct action (credentials, provider UI, DNS changes).  
Sections marked **[SCRIPT]** can be run verbatim from the command line.

---

## Phase 0 — Pre-Migration Prep

**Do this a few days before migration day, not the day of.**

### 0.1 [YOU] Provision the new VPS

Minimum specs:
- **200 GB disk** (current usage ~77 GB; openclaw image alone is 15.8 GB)
- **4 GB RAM** minimum, 8 GB recommended (n8n + openclaw gateway are memory-heavy)
- **Ubuntu 22.04 LTS** (matches current host)
- Same region as current VPS (reduces DNS TTL propagation time)

After provisioning, note the new IP — referred to as `NEW_IP` throughout this doc.

### 0.2 [YOU] Lower DNS TTL

In your DNS provider, set TTL to **300 seconds (5 min)** on all A records for:
- `classicslens.com`
- `www.classicslens.com`
- `n8n.nerdbox.com`
- `openclaw.nerdbox.com`
- `conductor.nerdbox.com`
- `files.nerdbox.com`

Do this **48 hours before** migration day so caches expire. You'll flip these to `NEW_IP` on
cutover day.

### 0.3 [SCRIPT] Verify latest backup is fresh

```bash
# On current VPS
tail -20 /var/log/openclaw-backup.log
# Should show a successful run within the last 24 hours
# If not, trigger manually:
bash /var/lib/docker/volumes/openclaw_workspace/_data/scripts/full-backup.sh
```

### 0.4 [SCRIPT] Record current state (row counts, file checksums)

Run this on the **current VPS** and save the output — you'll compare against it after restore:

```bash
echo "=== Row counts ===" 
docker exec n8n-postgres-1 psql -U n8n -d agents \
  -c "SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 15;"
docker exec n8n-postgres-1 psql -U n8n -d conductor_db \
  -c "SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 15;"
docker exec lector-postgres psql -U lector -d lector \
  -c "SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 15;"

echo "=== Volume sizes ==="
du -sh /var/lib/docker/volumes/openclaw_config/_data
du -sh /var/lib/docker/volumes/openclaw_workspace/_data
du -sh /var/lib/docker/volumes/lector_data/_data
du -sh /var/lib/docker/volumes/n8n_n8n_data/_data

echo "=== File count in workspace ==="
find /var/lib/docker/volumes/openclaw_workspace/_data -type f | wc -l
```

Save this as `migration-baseline.txt` somewhere safe (paste it into a gist or local file).

### 0.5 [YOU] Locate your secrets

You'll need these on the new server. Find them now, not during migration:

- S3 credentials (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `S3_BUCKET`, `S3_ENDPOINT`)
  → in `/root/openclaw/.env` on the current VPS
- `BACKUP_ENCRYPTION_PASSPHRASE` (if set) → same `.env`
- `SESSION_SECRET` for ClassicsLens → `/opt/lector/.env`
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` for ClassicsLens OAuth → same
- `RESEND_API_KEY`, `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY` → same
- `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID` → `/root/openclaw/.env`
- `DASHBOARD_TOKEN` for conductor → `/root/n8n/` or compose files
- `BRIDGE_TOKEN` for openclaw-bridge → compose files
- GOG OAuth tokens → `/root/.config/gogcli/`

```bash
# Quick dump of all .env files (review before copying)
cat /root/openclaw/.env
cat /opt/lector/.env
grep -r 'TOKEN\|SECRET\|KEY\|PASS' /root/n8n/ 2>/dev/null | grep -v '.git'
```

---

## Phase 1 — New VPS Bootstrap

**Do these steps on the new VPS.**

### 1.1 [SCRIPT] Install Docker

```bash
# On NEW VPS
apt-get update && apt-get install -y ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
  https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
  | tee /etc/apt/sources.list.d/docker.list
apt-get update && apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
```

### 1.2 [SCRIPT] Install supporting tools

```bash
apt-get install -y awscli git python3 python3-pip python3-venv iptables-persistent curl jq gpg
```

### 1.3 [YOU] Install ollama

```bash
curl -fsSL https://ollama.com/install.sh | sh
# After install, pull the models (this takes a while):
ollama pull llama3.2:3b
ollama pull nomic-embed-text
```

### 1.4 [SCRIPT] Clone the conductor repo (gives you infra files and scripts)

```bash
# On NEW VPS — substitute your actual token
git clone https://<TOKEN>@github.com/rutgersguy/conductor.git /var/lib/docker/volumes/openclaw_workspace/_data
# Create the Docker volume directory structure that the clone now lives in
mkdir -p /var/lib/docker/volumes/openclaw_config/_data
mkdir -p /var/lib/docker/volumes/openclaw_logs/_data
mkdir -p /var/lib/docker/volumes/openclaw_node_config/_data
mkdir -p /var/lib/docker/volumes/n8n_n8n_data/_data
mkdir -p /var/lib/docker/volumes/lector_data/_data
mkdir -p /var/lib/docker/volumes/lector_pg_data/_data
mkdir -p /var/lib/docker/volumes/n8n_postgres_data/_data
```

### 1.5 [SCRIPT] Copy host config from current VPS

From the **current VPS**, scp key directories to the new one:

```bash
# Run from CURRENT VPS — replace NEW_IP
scp -r /root/n8n root@NEW_IP:/root/n8n
scp -r /root/openclaw root@NEW_IP:/root/openclaw
scp -r /opt/lector root@NEW_IP:/opt/lector
scp -r /opt/openclaw-executor root@NEW_IP:/opt/openclaw-executor
scp -r /root/.config/gogcli root@NEW_IP:/root/.config/gogcli
```

### 1.6 [SCRIPT] Restore openclaw-executor venv on new VPS

```bash
# On NEW VPS
cd /opt/openclaw-executor
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```

---

## Phase 2 — Data Restore from Backup

**Do these steps on the new VPS. Keep the current VPS fully running — no changes yet.**

### 2.1 [SCRIPT] Download the latest backup from S3

```bash
# On NEW VPS
# Load S3 creds from the .env you copied over
export $(grep -E '^(AWS_|S3_|BACKUP_)' /root/openclaw/.env | xargs)

# List available backups, pick the most recent
aws --endpoint-url $S3_ENDPOINT s3 ls s3://$S3_BUCKET/full-backups/ | tail -5

# Set this to the most recent date shown
BACKUP_DATE="YYYY-MM-DD_HH-MM"
mkdir -p /tmp/restore-$BACKUP_DATE
aws --endpoint-url $S3_ENDPOINT s3 cp \
  s3://$S3_BUCKET/full-backups/$BACKUP_DATE/ \
  /tmp/restore-$BACKUP_DATE/ --recursive
```

### 2.2 [SCRIPT] Decrypt backup (if encrypted)

```bash
PASS="$(grep BACKUP_ENCRYPTION_PASSPHRASE /root/openclaw/.env | cut -d= -f2-)"
RESTORE_DIR="/tmp/restore-$BACKUP_DATE"

find "$RESTORE_DIR" -name "*.gpg" | while read -r f; do
  out="${f%.gpg}"
  gpg --decrypt --batch --passphrase "$PASS" "$f" > "$out" && rm "$f"
  echo "Decrypted: $out"
done
```

### 2.3 [SCRIPT] Bring up just the postgres containers (no app containers yet)

```bash
# n8n postgres
cd /root/n8n && docker compose up -d postgres
sleep 10

# lector postgres
cd /opt/lector && docker compose up -d db
sleep 10
```

### 2.4 [SCRIPT] Restore all databases

```bash
RESTORE_DIR="/tmp/restore-$BACKUP_DATE"

# Create databases if they don't exist yet
docker exec n8n-postgres-1 psql -U n8n -c "CREATE DATABASE agents;" 2>/dev/null || true
docker exec n8n-postgres-1 psql -U n8n -c "CREATE DATABASE conductor_db;" 2>/dev/null || true

# Restore agents DB
docker exec -i n8n-postgres-1 pg_restore -U n8n -d agents --clean --if-exists \
  < "$RESTORE_DIR/host/postgres-agents.dump"

# Restore conductor_db
docker exec -i n8n-postgres-1 pg_restore -U n8n -d conductor_db --clean --if-exists \
  < "$RESTORE_DIR/host/postgres-conductor_db.dump"

# Restore n8n DB
docker exec -i n8n-postgres-1 pg_restore -U n8n -d n8n --clean --if-exists \
  < "$RESTORE_DIR/host/postgres-n8n.dump"

# Restore ClassicsLens DB
docker exec -i lector-postgres pg_restore -U lector -d lector --clean --if-exists \
  < "$RESTORE_DIR/host/postgres-lector.dump"
```

### 2.5 [SCRIPT] Restore Docker volumes

```bash
RESTORE_DIR="/tmp/restore-$BACKUP_DATE"

# openclaw config, workspace, logs
tar xzf "$RESTORE_DIR/volumes/openclaw_config.tar.gz" \
  -C /var/lib/docker/volumes/openclaw_config/_data/
tar xzf "$RESTORE_DIR/volumes/openclaw_workspace.tar.gz" \
  -C /var/lib/docker/volumes/openclaw_workspace/_data/
tar xzf "$RESTORE_DIR/volumes/openclaw_logs.tar.gz" \
  -C /var/lib/docker/volumes/openclaw_logs/_data/

# n8n data
tar xzf "$RESTORE_DIR/volumes/n8n_n8n_data.tar.gz" \
  -C /var/lib/docker/volumes/n8n_n8n_data/_data/

# ClassicsLens data (morphology.db — ~500MB, takes a few minutes)
tar xzf "$RESTORE_DIR/volumes/lector_data.tar.gz" \
  -C /var/lib/docker/volumes/lector_data/_data/
```

### 2.6 [SCRIPT] Restore crontab and systemd units

```bash
RESTORE_DIR="/tmp/restore-$BACKUP_DATE"

crontab "$RESTORE_DIR/host/crontab-root.txt"

# Systemd ollama override
mkdir -p /etc/systemd/system/ollama.service.d
cp "$RESTORE_DIR/host/systemd/ollama-override.conf" \
   /etc/systemd/system/ollama.service.d/override.conf
systemctl daemon-reload && systemctl restart ollama
```

---

## Phase 3 — Bring Up All Services (New VPS, Pre-Cutover)

At this point the new VPS is fully loaded with data but DNS still points to the old VPS.
You can test everything using `/etc/hosts` overrides on your local machine.

### 3.1 [SCRIPT] Pull images and start all containers

```bash
# n8n stack (nginx, n8n, postgres, certbot, postgrest, bridge, sms-webhook)
cd /root/n8n && docker compose up -d

# openclaw stack (gateway, file-server, backup)
cd /root/openclaw && docker compose up -d
# OR if using infra path:
cd /var/lib/docker/volumes/openclaw_workspace/_data/infra/openclaw && docker compose up -d

# conductor dashboard
cd /var/lib/docker/volumes/openclaw_workspace/_data && \
  docker compose -p conductor-dashboard up -d

# ClassicsLens
cd /opt/lector && docker compose up -d
```

> **Note:** The `openclaw:local` image is 15.8 GB and must be rebuilt or transferred.
> See Section 3.1a below.

#### 3.1a [YOU] Handle the openclaw:local image

The openclaw gateway image is not in a public registry. Two options:

**Option A — Transfer from old VPS (faster):**
```bash
# On CURRENT VPS
docker save openclaw:local | gzip > /tmp/openclaw-local.tar.gz
scp /tmp/openclaw-local.tar.gz root@NEW_IP:/tmp/

# On NEW VPS
docker load < /tmp/openclaw-local.tar.gz
```
> This transfers ~8–10 GB compressed. On a fast connection, ~10–20 min.

**Option B — Rebuild on new VPS:**
```bash
# On NEW VPS (requires openclaw source — ask the openclaw team for Dockerfile)
cd /path/to/openclaw-source
docker build -t openclaw:local .
```

### 3.2 [SCRIPT] Issue SSL certs on new VPS

The TLS certs from the old VPS are included in the backup (`host/n8n-stack.tar.gz`), so they
should already be present in `/root/n8n/certbot/`. Verify:

```bash
ls -la /root/n8n/certbot/conf/live/
# Should show: classicslens.com  n8n.nerdbox.com  openclaw.nerdbox.com  etc.
```

If certs are missing or expired, you'll need to issue new ones **after** DNS cutover (certbot
needs the domain pointing to the new IP for HTTP-01 challenge). Plan for a brief cert-less
window or use `--staging` first:

```bash
# Post-cutover cert issuance (if needed)
docker exec n8n-certbot-1 certbot certonly --webroot \
  -w /var/www/certbot \
  -d classicslens.com -d www.classicslens.com \
  -d n8n.nerdbox.com -d openclaw.nerdbox.com \
  -d conductor.nerdbox.com -d files.nerdbox.com \
  --email your@email.com --agree-tos --non-interactive
docker restart n8n-nginx-1
```

### 3.3 [SCRIPT] Configure firewall on new VPS

```bash
# Install iptables-persistent if not already done
apt-get install -y iptables-persistent

# Bring up containers first so IPs are assigned, then:
CONDUCTOR_IP=$(docker inspect conductor-nginx \
  --format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' | awk '{print $1}')
FILESERVER_IP=$(docker inspect openclaw-file-server \
  --format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' | awk '{print $1}')

echo "conductor-nginx: $CONDUCTOR_IP  |  file-server: $FILESERVER_IP"

iptables -F DOCKER-USER
iptables -I DOCKER-USER 1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -I DOCKER-USER 2 -d "$CONDUCTOR_IP" -p tcp --dport 80 ! -s 172.16.0.0/12 -j DROP
iptables -I DOCKER-USER 3 -d "$FILESERVER_IP" -p tcp --dport 80 ! -s 172.16.0.0/12 -j DROP

iptables-save > /etc/iptables/rules.v4
```

---

## Phase 4 — Pre-Cutover Testing

**Test on the new VPS before touching DNS. Use `/etc/hosts` on your local machine to simulate
the DNS change without affecting anyone else.**

### 4.1 [YOU] Add local hosts overrides

On your laptop/desktop, add to `/etc/hosts` (Mac/Linux) or
`C:\Windows\System32\drivers\etc\hosts` (Windows):

```
NEW_IP  classicslens.com www.classicslens.com
NEW_IP  n8n.nerdbox.com
NEW_IP  openclaw.nerdbox.com
NEW_IP  conductor.nerdbox.com
NEW_IP  files.nerdbox.com
```

Now your browser will hit the new server while everyone else still hits the old one.

### 4.2 Pre-Cutover Test Plan

Run all checks below. Every item must pass before proceeding to DNS cutover.

#### Infrastructure health

```bash
# On NEW VPS — run these directly

# All containers running
docker ps --format 'table {{.Names}}\t{{.Status}}' | grep -v "Exited"
# Expected: 14 containers Up (certbot will show Exited — that's normal)

# Gateway health
curl -s http://localhost:18789/health
# Expected: {"status":"ok"} or similar

# Conductor backend health  
curl -s -H "Authorization: Bearer $(grep DASHBOARD_TOKEN /root/n8n/.env | cut -d= -f2-)" \
  http://localhost:8001/api/home/summary | jq '.status'

# PostgREST responding
curl -s http://localhost:3000/ | head -c 100

# Ollama responding
curl -s http://localhost:11434/api/tags | jq '.models[].name'
# Expected: llama3.2:3b, nomic-embed-text
```

#### Database integrity

```bash
# Compare row counts against migration-baseline.txt

docker exec n8n-postgres-1 psql -U n8n -d agents \
  -c "SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 15;"

docker exec n8n-postgres-1 psql -U n8n -d conductor_db \
  -c "SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 15;"

docker exec lector-postgres psql -U lector -d lector \
  -c "SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 15;"

# ClassicsLens: verify passage count and user count
docker exec lector-postgres psql -U lector -d lector \
  -c "SELECT count(*) FROM passages; SELECT count(*) FROM users;"
# Expected: ~9755 passages, user count matches baseline
```

#### ClassicsLens (via /etc/hosts override in browser)

- [ ] `https://classicslens.com` loads (no SSL error)
- [ ] Login with Google OAuth succeeds (`broadnax@gmail.com`)
- [ ] Today's passage loads with Latin/Greek text
- [ ] Click a word → gloss appears
- [ ] Admin panel accessible at `/#/admin`
- [ ] User list matches expected count

#### n8n

- [ ] `https://n8n.nerdbox.com` loads
- [ ] Login succeeds
- [ ] Workflow list visible (count matches old server)
- [ ] Spot-check one active webhook workflow is enabled

#### OpenClaw gateway

- [ ] `https://openclaw.nerdbox.com` loads
- [ ] WebSocket connection establishes (check browser console — no WS errors)
- [ ] Send a test message to the agent, get a response

#### Conductor dashboard

- [ ] `https://conductor.nerdbox.com` loads
- [ ] Heartbeat log shows recent entries
- [ ] Session activity visible

#### Files / diagrams

- [ ] `https://files.nerdbox.com/diagrams/` returns HTTP 200

#### Backup system

```bash
# Verify backup script will work on new VPS (dry run — don't actually upload)
export $(grep -E '^(AWS_|S3_)' /root/openclaw/.env | xargs)
aws --endpoint-url $S3_ENDPOINT s3 ls s3://$S3_BUCKET/full-backups/ | tail -3
# If this succeeds, S3 credentials are working
```

---

## Phase 5 — DNS Cutover

**Only proceed once all Phase 4 tests pass.**

### 5.1 [SCRIPT] Take a final backup from current VPS

```bash
# On CURRENT VPS — get the freshest possible data snapshot
bash /var/lib/docker/volumes/openclaw_workspace/_data/scripts/full-backup.sh
```

Wait for it to finish, then immediately restore this final snapshot to the new VPS:

```bash
# On NEW VPS — same steps as Phase 2, using today's backup date
BACKUP_DATE="$(date -u '+%Y-%m-%d_02-00')"  # adjust to actual timestamp
# ... repeat steps 2.1 through 2.5 with the fresh backup
```

### 5.2 [YOU] Flip DNS

In your DNS provider, update **all A records** to `NEW_IP`:

| Record | Old value | New value |
|--------|-----------|-----------|
| `classicslens.com` | OLD_IP | NEW_IP |
| `www.classicslens.com` | OLD_IP | NEW_IP |
| `n8n.nerdbox.com` | OLD_IP | NEW_IP |
| `openclaw.nerdbox.com` | OLD_IP | NEW_IP |
| `conductor.nerdbox.com` | OLD_IP | NEW_IP |
| `files.nerdbox.com` | OLD_IP | NEW_IP |

TTL is already at 5 min (set in Phase 0.2), so propagation happens within ~5 minutes.

### 5.3 [YOU] Stop inbound traffic on the old VPS

Once DNS is flipped, stop the old server's app containers to prevent split-brain writes
(particularly ClassicsLens users completing passages):

```bash
# On OLD VPS — stop app containers, leave postgres running briefly for emergency access
docker stop lector-app n8n-n8n-1 openclaw-openclaw-gateway-1 conductor-backend
```

### 5.4 [SCRIPT] Remove /etc/hosts overrides

On your local machine, remove the lines you added in Phase 4.1. Confirm your browser
now resolves to the new server via real DNS:

```bash
dig classicslens.com +short  # Should return NEW_IP
```

---

## Phase 6 — Post-Cutover Verification

### 6.1 [SCRIPT] Run the full health check suite

```bash
# On NEW VPS
echo "=== Container status ==="
docker ps --format 'table {{.Names}}\t{{.Status}}'

echo "=== ClassicsLens DB ==="
docker exec lector-postgres psql -U lector -d lector \
  -c "SELECT count(*) FROM users; SELECT count(*) FROM passage_completions;"

echo "=== Gateway ==="
curl -s http://localhost:18789/health

echo "=== Conductor ==="
curl -s -H "Authorization: Bearer $(grep DASHBOARD_TOKEN /root/n8n/.env | cut -d= -f2-)" \
  http://localhost:8001/api/heartbeats?limit=1 | jq '.[0].created_at'

echo "=== Crontab ==="
crontab -l | wc -l  # Should be > 10

echo "=== Firewall ==="
iptables -L DOCKER-USER -n
```

### 6.2 [YOU] Verify SSL certs are serving correctly

```bash
# From your local machine (NOT the VPS)
curl -sv https://classicslens.com 2>&1 | grep -E 'subject|expire|SSL'
curl -sv https://n8n.nerdbox.com 2>&1 | grep -E 'subject|expire|SSL'
```

### 6.3 [YOU] Do a real end-to-end test

- Log into ClassicsLens with your actual Google account
- Complete a passage — verify it records in the DB:
  ```bash
  docker exec lector-postgres psql -U lector -d lector \
    -c "SELECT * FROM passage_completions ORDER BY completed_at DESC LIMIT 3;"
  ```
- Send a message to OpenClaw, verify response
- Check conductor dashboard shows a new heartbeat

### 6.4 [SCRIPT] Verify backup runs on new VPS (next morning)

```bash
# Morning after migration — check last night's backup ran from the NEW VPS
tail -30 /var/log/openclaw-backup.log
# Should show: "Backup complete" with today's date and uploads to S3
```

---

## Phase 7 — Cleanup

**Wait at least 48 hours after cutover before decommissioning the old VPS.**

### 7.1 [YOU] Decommission old VPS

Once you're confident everything is working:

1. Take one final manual backup from the old VPS (belt and suspenders)
2. Snapshot the old VPS volume if your provider supports it (instant rollback option)
3. Power off or destroy the old VPS

### 7.2 [YOU] Restore DNS TTL to normal

Set TTL back to 3600 (1 hour) or whatever your normal value is.

### 7.3 [SCRIPT] Update Google OAuth redirect URIs

ClassicsLens uses Google OAuth. If the new VPS has a different domain (**it shouldn't** —
you're migrating same domains), you'd need to update the Google Cloud Console.
Since domains are staying the same, **no action needed** unless you added a new domain.

---

## Rollback Plan

If something goes catastrophically wrong after DNS cutover:

1. **[YOU]** Flip DNS back to OLD_IP immediately (5 min propagation)
2. **[SCRIPT]** Restart stopped containers on old VPS:
   ```bash
   docker start lector-app n8n-n8n-1 openclaw-openclaw-gateway-1 conductor-backend
   ```
3. Assess what went wrong on the new VPS before trying again
4. Any data written to the new VPS during the window is lost — this is the only data loss risk.
   To minimize it: keep the cutover window short (Steps 5.1–5.3 should take under 15 minutes).

---

## Data Loss Risk Assessment

| Data | Risk | Mitigation |
|------|------|------------|
| ClassicsLens user accounts, SRS progress | Low — restored from backup | Final backup taken seconds before cutover |
| Passage completions during cutover window | Low — 15 min max window | Stop old app containers immediately after DNS flip |
| n8n workflow definitions | Very low — rarely changed | Backed up nightly |
| OpenClaw conversation history | Low — JSONL files in volume | Restored from backup |
| Agent memory (memory_kv) | Low | Restored from agents DB dump |
| In-flight n8n workflow runs | Medium — any running at cutover are lost | Accept as known risk; retry manually if needed |

---

## Estimated Timeline

| Phase | Activity | Duration | Who |
|-------|----------|----------|-----|
| Phase 0 | Prep (TTL, secrets, baseline) | 1–2 hours | You + script |
| Phase 1 | New VPS bootstrap | 30–60 min | Script (+ ollama pull ~20 min) |
| Phase 2 | Data restore from S3 | 30–60 min | Script |
| Phase 3 | Bring up services, image transfer | 30–60 min | Script (openclaw image transfer is the long pole) |
| Phase 4 | Pre-cutover testing | 30–45 min | You |
| Phase 5 | DNS cutover + final sync | 15–20 min | You + script |
| Phase 6 | Post-cutover verification | 20–30 min | You + script |
| **Total** | | **~4–6 hours** | |

---

## Gotchas and Things That Will Bite You

1. **openclaw:local image is 15.8 GB.** Plan for this transfer. Don't start migration day without
   it already on the new server.

2. **Firewall rules target container IPs.** IPs will be different on the new host. Always run
   Phase 3.3 (firewall setup) **after** containers are up, not before.

3. **Two copies of openclaw.json.** The repo copy at `infra/openclaw/openclaw.json` is a
   reference only. The live config is at `/var/lib/docker/volumes/openclaw_config/_data/openclaw.json`.
   The volume restore (Phase 2.5) handles this correctly — don't overwrite it manually afterward.

4. **conductor-dashboard compose project name.** Always use `docker compose -p conductor-dashboard`
   from the workspace directory. Running without `-p` creates a different project and breaks
   the internal DNS.

5. **n8n credentials are encrypted with an instance-specific key.** If n8n shows "Credentials
   could not be decrypted" after restore, the encryption key in the restored `.env` doesn't
   match the one used when creds were saved. Fix: ensure `N8N_ENCRYPTION_KEY` in `/root/n8n/.env`
   on the new server **exactly matches** the value from the old server. The backup includes
   this file — don't regenerate it.

6. **Let's Encrypt rate limits.** If you request new certs too many times (5 per domain per
   week), you'll be rate-limited. Use the backed-up certs from the old server wherever possible.
   Only issue new certs if they're expired or missing.

7. **ClassicsLens morphology.db is 500 MB.** The `lector_data` volume restore takes a few
   minutes. Don't abort it early.

8. **Ollama model weights are not in the backup.** You must re-pull `llama3.2:3b` and
   `nomic-embed-text` fresh on the new server (Phase 1.3). Each pull is ~2–4 GB.

9. **cron paths are absolute.** The crontab references
   `/var/lib/docker/volumes/openclaw_workspace/_data/scripts/...` — these paths must exist on
   the new server exactly as-is. The workspace volume restore handles this.

10. **Google OAuth callback URL.** The callback is hardcoded to `https://classicslens.com/api/auth/google/callback`.
    As long as you're migrating to the same domain (you are), no Google Cloud Console change
    is needed.
