server-ovh-prod-01-main/docs/superpowers/plans/2026-05-14-migrate-n8n-form...

671 lines
21 KiB
Markdown

# Service Migration: n8n (prod + test) + Formbricks → OVH
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Migrate three Docker Compose services (n8n production, n8n-test, formbricks) from hyperthrive-docker to OVH (jared@15.204.247.153), preserving all data, coordinated around a user-initiated DNS cutover.
**Architecture:** Each service is stopped on the source to capture a consistent volume snapshot, volumes are exported as tarballs and transferred over SSH, service directories are created on OVH with adapted configs (network and cert resolver names differ between servers), volumes are imported, services are started and health-checked, then the user cuts DNS. Tasks 1, 2, and 3 are fully independent and can run in parallel.
**Tech Stack:** Docker Compose v2, Traefik v3.6, PostgreSQL 16, pgvector v0.5.1, n8n, Formbricks
---
## Config Differences: Source → Target
| Setting | hyperthrive-docker (source) | OVH / 15.204.247.153 (target) |
|---|---|---|
| External Traefik network | `reverse-proxy-nw` | `traefik` |
| Traefik cert resolver | `myresolver` | `letsencrypt` |
| Services path | `~/services/` | `~/services/` |
---
## Task 1: Migrate n8n Production — n8n.hyperthrive.io
**Model tier:** Sonnet
**Files on OVH:**
- Create: `~/services/n8n/docker-compose.yml`
- Create: `~/services/n8n/.env`
- Create: `~/services/n8n/init-data.sh`
**Volumes to migrate:**
- `n8n_db_storage` → PostgreSQL data (postgres container)
- `n8n_n8n_storage` → n8n app data and encryption key (n8n container)
> ⚠️ Both volumes MUST be migrated together. The n8n app volume contains the encryption key used to protect credentials stored in the database. Migrating only the database will leave credentials unreadable.
- [ ] **Step 1: Stop n8n stack on source to get consistent data**
```bash
ssh hyperthrive-docker "cd ~/services/n8n && docker compose stop"
```
Expected: containers stop, no error
- [ ] **Step 2: Export postgres volume from source**
```bash
ssh hyperthrive-docker "docker run --rm -v n8n_db_storage:/data alpine tar czf - -C /data ." > /tmp/n8n_db_storage.tar.gz
```
Expected: file `/tmp/n8n_db_storage.tar.gz` created locally, non-zero size
- [ ] **Step 3: Export n8n app data volume from source**
```bash
ssh hyperthrive-docker "docker run --rm -v n8n_n8n_storage:/data alpine tar czf - -C /data ." > /tmp/n8n_n8n_storage.tar.gz
```
Expected: file `/tmp/n8n_n8n_storage.tar.gz` created locally, non-zero size
- [ ] **Step 4: Restart n8n on source (minimise source downtime)**
```bash
ssh hyperthrive-docker "cd ~/services/n8n && docker compose start"
```
Expected: containers running again
- [ ] **Step 5: Create service directory on OVH**
```bash
ssh jared@15.204.247.153 "mkdir -p ~/services/n8n"
```
- [ ] **Step 6: Write docker-compose.yml on OVH**
```bash
ssh jared@15.204.247.153 "cat > ~/services/n8n/docker-compose.yml" << 'COMPOSE'
volumes:
db_storage:
n8n_storage:
services:
postgres:
image: postgres:16
restart: always
environment:
- POSTGRES_USER
- POSTGRES_PASSWORD
- POSTGRES_DB
- POSTGRES_NON_ROOT_USER
- POSTGRES_NON_ROOT_PASSWORD
volumes:
- db_storage:/var/lib/postgresql/data
- ./init-data.sh:/docker-entrypoint-initdb.d/init-data.sh
healthcheck:
test: ['CMD-SHELL', 'pg_isready -h localhost -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
interval: 5s
timeout: 5s
retries: 10
networks:
- internal
n8n:
image: docker.n8n.io/n8nio/n8n:latest
restart: always
environment:
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=${POSTGRES_DB}
- DB_POSTGRESDB_USER=${POSTGRES_NON_ROOT_USER}
- DB_POSTGRESDB_PASSWORD=${POSTGRES_NON_ROOT_PASSWORD}
- N8N_HOST=${N8N_HOST}
- N8N_PORT=5678
- N8N_PROTOCOL=https
- N8N_TRUSTED_PROXIES=1
- N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true
- N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true
- EXECUTIONS_DATA_MAX_AGE=72
- NODE_ENV=production
- WEBHOOK_URL=https://${N8N_HOST}
- QUEUE_HEALTH_CHECK_ACTIVE=true
- NODE_FUNCTION_ALLOW_BUILTIN=crypto
healthcheck:
test: ["CMD", "wget", "--no-check-certificate", "--spider", "http://127.0.0.1:5678/healthz"]
interval: 12s
timeout: 10s
retries: 3
start_period: 15s
volumes:
- n8n_storage:/home/node/.n8n
depends_on:
postgres:
condition: service_healthy
networks:
- internal
- traefik
labels:
- "traefik.enable=true"
- "traefik.http.routers.n8n.rule=Host(`n8n.hyperthrive.io`) && !PathPrefix(`/mcp`)"
- "traefik.http.routers.n8n.entrypoints=websecure"
- "traefik.http.routers.n8n.tls.certresolver=letsencrypt"
- "traefik.http.routers.n8n.service=n8n"
- "traefik.http.routers.n8n-mcp.rule=Host(`n8n.hyperthrive.io`) && PathPrefix(`/mcp`)"
- "traefik.http.routers.n8n-mcp.entrypoints=websecure"
- "traefik.http.routers.n8n-mcp.tls.certresolver=letsencrypt"
- "traefik.http.routers.n8n-mcp.service=n8n"
- "traefik.http.routers.n8n-mcp.middlewares=no-buffer@docker"
- "traefik.http.middlewares.no-buffer.headers.customResponseHeaders.X-Accel-Buffering=no"
- "traefik.http.middlewares.no-buffer.headers.customResponseHeaders.Cache-Control=no-cache"
- "traefik.http.middlewares.no-buffer.compress=false"
- "traefik.http.services.n8n.loadbalancer.server.port=5678"
- "traefik.docker.network=traefik"
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
internal:
traefik:
external: true
COMPOSE
```
- [ ] **Step 7: Write .env on OVH**
```bash
ssh jared@15.204.247.153 "cat > ~/services/n8n/.env" << 'ENV'
POSTGRES_USER=n8n_user_pg
POSTGRES_PASSWORD=JS7FJQaVj8dhPo3j5#G69
POSTGRES_DB=n8n
POSTGRES_NON_ROOT_USER=n8n_non_root_pg
POSTGRES_NON_ROOT_PASSWORD=WzG$9GqluVmywLCiAuqTz
N8N_HOST=n8n.hyperthrive.io
N8N_VERSION=1.71.3
ENV
```
- [ ] **Step 8: Write init-data.sh on OVH**
```bash
ssh jared@15.204.247.153 "cat > ~/services/n8n/init-data.sh" << 'SCRIPT'
#!/bin/bash
set -e;
if [ -n "${POSTGRES_NON_ROOT_USER:-}" ] && [ -n "${POSTGRES_NON_ROOT_PASSWORD:-}" ]; then
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
CREATE USER ${POSTGRES_NON_ROOT_USER} WITH PASSWORD '${POSTGRES_NON_ROOT_PASSWORD}';
GRANT ALL PRIVILEGES ON DATABASE ${POSTGRES_DB} TO ${POSTGRES_NON_ROOT_USER};
GRANT CREATE ON SCHEMA public TO ${POSTGRES_NON_ROOT_USER};
EOSQL
else
echo "SETUP INFO: No Environment variables given!"
fi
SCRIPT
ssh jared@15.204.247.153 "chmod +x ~/services/n8n/init-data.sh"
```
- [ ] **Step 9: Transfer volume tarballs to OVH**
```bash
scp /tmp/n8n_db_storage.tar.gz jared@15.204.247.153:/tmp/
scp /tmp/n8n_n8n_storage.tar.gz jared@15.204.247.153:/tmp/
```
Expected: both files appear in `/tmp/` on OVH
- [ ] **Step 10: Create Docker volumes on OVH and import data**
```bash
ssh jared@15.204.247.153 "
docker volume create n8n_db_storage &&
docker volume create n8n_n8n_storage &&
docker run --rm -v n8n_db_storage:/data -v /tmp:/backup alpine sh -c 'tar xzf /backup/n8n_db_storage.tar.gz -C /data' &&
docker run --rm -v n8n_n8n_storage:/data -v /tmp:/backup alpine sh -c 'tar xzf /backup/n8n_n8n_storage.tar.gz -C /data'
"
```
Expected: no errors
- [ ] **Step 11: Start n8n stack on OVH**
```bash
ssh jared@15.204.247.153 "cd ~/services/n8n && docker compose up -d"
```
Expected: postgres starts first (healthcheck passes), then n8n starts
- [ ] **Step 12: Verify n8n is healthy on OVH**
```bash
ssh jared@15.204.247.153 "docker compose -f ~/services/n8n/docker-compose.yml ps"
```
Expected: both containers show `Up` and postgres shows `(healthy)`
```bash
ssh jared@15.204.247.153 "docker exec \$(docker ps -qf name=n8n-n8n) wget -qO- http://127.0.0.1:5678/healthz"
```
Expected: `{"status":"ok"}` or similar 200 response
- [ ] **Step 13: Clean up temp files**
```bash
ssh jared@15.204.247.153 "rm /tmp/n8n_db_storage.tar.gz /tmp/n8n_n8n_storage.tar.gz"
rm /tmp/n8n_db_storage.tar.gz /tmp/n8n_n8n_storage.tar.gz
```
- [ ] **Step 14: Notify user — n8n ready for DNS cutover**
Report: n8n production is running on OVH. User can now point `n8n.hyperthrive.io` to 15.204.247.153. After DNS propagates, stop source: `ssh hyperthrive-docker "cd ~/services/n8n && docker compose stop"`
---
## Task 2: Migrate n8n-test — testn8n.hyperthrive.io
**Model tier:** Sonnet
**Files on OVH:**
- Create: `~/services/n8n-test/docker-compose.yml`
- Create: `~/services/n8n-test/.env`
- Create: `~/services/n8n-test/init-data.sh`
**Volumes to migrate:**
- `n8n-test_test_n8n_db_storage` → PostgreSQL data
- `n8n-test_test_n8n_storage` → n8n app data and encryption key
- [ ] **Step 1: Stop n8n-test stack on source**
```bash
ssh hyperthrive-docker "cd ~/services/n8n-test && docker compose stop"
```
- [ ] **Step 2: Export postgres volume from source**
```bash
ssh hyperthrive-docker "docker run --rm -v n8n-test_test_n8n_db_storage:/data alpine tar czf - -C /data ." > /tmp/n8n_test_db.tar.gz
```
- [ ] **Step 3: Export n8n-test app data volume from source**
```bash
ssh hyperthrive-docker "docker run --rm -v n8n-test_test_n8n_storage:/data alpine tar czf - -C /data ." > /tmp/n8n_test_storage.tar.gz
```
- [ ] **Step 4: Restart n8n-test on source**
```bash
ssh hyperthrive-docker "cd ~/services/n8n-test && docker compose start"
```
- [ ] **Step 5: Create service directory on OVH**
```bash
ssh jared@15.204.247.153 "mkdir -p ~/services/n8n-test"
```
- [ ] **Step 6: Write docker-compose.yml on OVH**
```bash
ssh jared@15.204.247.153 "cat > ~/services/n8n-test/docker-compose.yml" << 'COMPOSE'
volumes:
test_n8n_db_storage:
test_n8n_storage:
services:
postgres:
image: postgres:16
restart: always
environment:
- POSTGRES_USER
- POSTGRES_PASSWORD
- POSTGRES_DB
- POSTGRES_NON_ROOT_USER
- POSTGRES_NON_ROOT_PASSWORD
volumes:
- test_n8n_db_storage:/var/lib/postgresql/data
- ./init-data.sh:/docker-entrypoint-initdb.d/init-data.sh
healthcheck:
test: ['CMD-SHELL', 'pg_isready -h localhost -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
interval: 5s
timeout: 5s
retries: 10
networks:
- test_n8n_internal
n8n:
image: docker.n8n.io/n8nio/n8n:latest
restart: always
environment:
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=${POSTGRES_DB}
- DB_POSTGRESDB_USER=${POSTGRES_NON_ROOT_USER}
- DB_POSTGRESDB_PASSWORD=${POSTGRES_NON_ROOT_PASSWORD}
- N8N_HOST=${N8N_HOST}
- N8N_PORT=5678
- N8N_PROTOCOL=https
- N8N_TRUSTED_PROXIES=1
- N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true
- N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true
- EXECUTIONS_DATA_MAX_AGE=72
- NODE_ENV=production
- WEBHOOK_URL=https://${N8N_HOST}
- QUEUE_HEALTH_CHECK_ACTIVE=true
healthcheck:
test: ["CMD", "wget", "--no-check-certificate", "--spider", "http://127.0.0.1:5678/healthz"]
interval: 12s
timeout: 10s
retries: 3
start_period: 15s
volumes:
- test_n8n_storage:/home/node/.n8n
depends_on:
postgres:
condition: service_healthy
networks:
- test_n8n_internal
- traefik
labels:
- "traefik.enable=true"
- "traefik.http.routers.testn8n.rule=Host(`testn8n.hyperthrive.io`)"
- "traefik.http.routers.testn8n.entrypoints=websecure"
- "traefik.http.routers.testn8n.tls.certresolver=letsencrypt"
- "traefik.http.routers.testn8n.service=testn8n"
- "traefik.http.services.testn8n.loadbalancer.server.port=5678"
- "traefik.docker.network=traefik"
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
test_n8n_internal:
traefik:
external: true
COMPOSE
```
- [ ] **Step 7: Write .env on OVH**
```bash
ssh jared@15.204.247.153 "cat > ~/services/n8n-test/.env" << 'ENV'
POSTGRES_USER=n8n_test_user_pg
POSTGRES_PASSWORD=l5t2xd8f%&RwB8aidK#2M
POSTGRES_DB=n8n_test
POSTGRES_NON_ROOT_USER=n8n_test_non_root_pg
POSTGRES_NON_ROOT_PASSWORD=isqjKb5s0NWa&w*N@aGbO
N8N_HOST=testn8n.hyperthrive.io
N8N_VERSION=latest
ENV
```
- [ ] **Step 8: Write init-data.sh on OVH**
```bash
ssh jared@15.204.247.153 "cat > ~/services/n8n-test/init-data.sh" << 'SCRIPT'
#!/bin/bash
set -e;
if [ -n "${POSTGRES_NON_ROOT_USER:-}" ] && [ -n "${POSTGRES_NON_ROOT_PASSWORD:-}" ]; then
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
CREATE USER ${POSTGRES_NON_ROOT_USER} WITH PASSWORD '${POSTGRES_NON_ROOT_PASSWORD}';
GRANT ALL PRIVILEGES ON DATABASE ${POSTGRES_DB} TO ${POSTGRES_NON_ROOT_USER};
GRANT CREATE ON SCHEMA public TO ${POSTGRES_NON_ROOT_USER};
EOSQL
else
echo "SETUP INFO: No Environment variables given!"
fi
SCRIPT
ssh jared@15.204.247.153 "chmod +x ~/services/n8n-test/init-data.sh"
```
- [ ] **Step 9: Transfer volume tarballs to OVH**
```bash
scp /tmp/n8n_test_db.tar.gz jared@15.204.247.153:/tmp/
scp /tmp/n8n_test_storage.tar.gz jared@15.204.247.153:/tmp/
```
- [ ] **Step 10: Create Docker volumes on OVH and import data**
```bash
ssh jared@15.204.247.153 "
docker volume create n8n-test_test_n8n_db_storage &&
docker volume create n8n-test_test_n8n_storage &&
docker run --rm -v n8n-test_test_n8n_db_storage:/data -v /tmp:/backup alpine sh -c 'tar xzf /backup/n8n_test_db.tar.gz -C /data' &&
docker run --rm -v n8n-test_test_n8n_storage:/data -v /tmp:/backup alpine sh -c 'tar xzf /backup/n8n_test_storage.tar.gz -C /data'
"
```
- [ ] **Step 11: Start n8n-test stack on OVH**
```bash
ssh jared@15.204.247.153 "cd ~/services/n8n-test && docker compose up -d"
```
- [ ] **Step 12: Verify n8n-test is healthy on OVH**
```bash
ssh jared@15.204.247.153 "docker compose -f ~/services/n8n-test/docker-compose.yml ps"
```
Expected: both containers `Up`, postgres `(healthy)`
```bash
ssh jared@15.204.247.153 "docker exec \$(docker ps -qf name=n8n-test-n8n) wget -qO- http://127.0.0.1:5678/healthz"
```
Expected: `{"status":"ok"}`
- [ ] **Step 13: Clean up temp files**
```bash
ssh jared@15.204.247.153 "rm /tmp/n8n_test_db.tar.gz /tmp/n8n_test_storage.tar.gz"
rm /tmp/n8n_test_db.tar.gz /tmp/n8n_test_storage.tar.gz
```
- [ ] **Step 14: Notify user — n8n-test ready for DNS cutover**
Report: n8n-test is running on OVH. User can now point `testn8n.hyperthrive.io` to 15.204.247.153. After DNS propagates, stop source: `ssh hyperthrive-docker "cd ~/services/n8n-test && docker compose stop"`
---
## Task 3: Migrate Formbricks — formbricks.hyperthrive.io
**Model tier:** Sonnet
**Files on OVH:**
- Create: `~/services/formbricks/docker-compose.yml`
- Create: `~/services/formbricks/.env`
**Volumes to migrate:**
- `formbricks_app_data` → Formbricks application data (mounted at `/app/data`)
- `formbricks_postgres_data` → PostgreSQL data (pgvector)
- Anonymous uploads volume → mounted at `/home/nextjs/apps/web/uploads` in the running container (may contain user-uploaded files; check size before deciding whether to migrate)
- [ ] **Step 1: Check if the anonymous uploads volume has data worth migrating**
```bash
ssh hyperthrive-docker "docker exec formbricks-formbricks-1 du -sh /home/nextjs/apps/web/uploads 2>/dev/null || echo 'path not found or empty'"
```
If output shows significant data (> a few KB), capture the volume hash for Step 3b. If empty or not found, skip Step 3b.
- [ ] **Step 2: Stop formbricks stack on source**
```bash
ssh hyperthrive-docker "cd ~/services/formbricks && docker compose stop"
```
- [ ] **Step 3: Export formbricks app data volume**
```bash
ssh hyperthrive-docker "docker run --rm -v formbricks_app_data:/data alpine tar czf - -C /data ." > /tmp/formbricks_app_data.tar.gz
```
- [ ] **Step 3b (conditional): Export anonymous uploads volume if it had data**
Get the volume hash from the docker inspect output: `4a21529d8e041b3ad60aa6cd2b821788c4a26a1245696ea90df324fe4a1f7e7a`
```bash
ssh hyperthrive-docker "docker run --rm -v 4a21529d8e041b3ad60aa6cd2b821788c4a26a1245696ea90df324fe4a1f7e7a:/data alpine tar czf - -C /data ." > /tmp/formbricks_uploads.tar.gz
```
Skip this step if Step 1 showed the directory was empty.
- [ ] **Step 4: Export postgres volume**
```bash
ssh hyperthrive-docker "docker run --rm -v formbricks_postgres_data:/data alpine tar czf - -C /data ." > /tmp/formbricks_postgres_data.tar.gz
```
- [ ] **Step 5: Restart formbricks on source**
```bash
ssh hyperthrive-docker "cd ~/services/formbricks && docker compose start"
```
- [ ] **Step 6: Create service directory on OVH**
```bash
ssh jared@15.204.247.153 "mkdir -p ~/services/formbricks"
```
- [ ] **Step 7: Write docker-compose.yml on OVH**
```bash
ssh jared@15.204.247.153 "cat > ~/services/formbricks/docker-compose.yml" << 'COMPOSE'
services:
formbricks:
image: formbricks/formbricks
restart: always
environment:
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
CRON_SECRET: ${CRON_SECRET}
WEBAPP_URL: ${WEBAPP_URL}
NEXTAUTH_URL: ${WEBAPP_URL}
SHORT_URL_BASE: ${WEBAPP_URL}
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/formbricks
PORT: 3000
EMAIL_VERIFICATION_DISABLED: 1
volumes:
- formbricks_app_data:/app/data
depends_on:
postgres:
condition: service_healthy
networks:
- internal
- traefik
labels:
- "traefik.enable=true"
- "traefik.http.routers.formbricks.rule=Host(`formbricks.hyperthrive.io`)"
- "traefik.http.routers.formbricks.entrypoints=websecure"
- "traefik.http.routers.formbricks.tls.certresolver=letsencrypt"
- "traefik.http.services.formbricks.loadbalancer.server.port=3000"
- "traefik.docker.network=traefik"
postgres:
image: ankane/pgvector:v0.5.1
restart: always
environment:
POSTGRES_DB: formbricks
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- formbricks_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
volumes:
formbricks_app_data:
name: formbricks_app_data
formbricks_postgres_data:
name: formbricks_postgres_data
networks:
internal:
name: formbricks_internal
traefik:
external: true
COMPOSE
```
- [ ] **Step 8: Write .env on OVH**
```bash
ssh jared@15.204.247.153 "cat > ~/services/formbricks/.env" << 'ENV'
NEXTAUTH_SECRET=bc366a86606242b11f88d107eecebe4cc83ff421d41d06142d191581f165dc46
ENCRYPTION_KEY=ed8c30d07f82183b0dd9f5a21df1fb5720072768c501e6631531ae73cca9e90e
CRON_SECRET=349af37fef8ccb87377c083c5a2783e45becf04668614a110de127f32ce2c612
POSTGRES_PASSWORD=9d9eXgtFn6gogV0fVWk7BSAF1MJjcFuwfU966sNVE0
WEBAPP_URL=https://formbricks.hyperthrive.io
ENV
```
- [ ] **Step 9: Transfer volume tarballs to OVH**
```bash
scp /tmp/formbricks_app_data.tar.gz jared@15.204.247.153:/tmp/
scp /tmp/formbricks_postgres_data.tar.gz jared@15.204.247.153:/tmp/
# If uploads were exported in Step 3b:
# scp /tmp/formbricks_uploads.tar.gz jared@15.204.247.153:/tmp/
```
- [ ] **Step 10: Create Docker volumes on OVH and import data**
```bash
ssh jared@15.204.247.153 "
docker volume create formbricks_app_data &&
docker volume create formbricks_postgres_data &&
docker run --rm -v formbricks_app_data:/data -v /tmp:/backup alpine sh -c 'tar xzf /backup/formbricks_app_data.tar.gz -C /data' &&
docker run --rm -v formbricks_postgres_data:/data -v /tmp:/backup alpine sh -c 'tar xzf /backup/formbricks_postgres_data.tar.gz -C /data'
"
```
- [ ] **Step 11: Start formbricks stack on OVH**
```bash
ssh jared@15.204.247.153 "cd ~/services/formbricks && docker compose up -d"
```
Expected: postgres starts first (healthcheck), then formbricks
- [ ] **Step 12: Verify formbricks is healthy on OVH**
```bash
ssh jared@15.204.247.153 "docker compose -f ~/services/formbricks/docker-compose.yml ps"
```
Expected: both containers `Up`, postgres `(healthy)`
```bash
ssh jared@15.204.247.153 "curl -sf http://localhost:3000/health 2>/dev/null || docker logs \$(docker ps -qf name=formbricks-formbricks) --tail 20"
```
Expected: 200 response or logs showing server started on port 3000
- [ ] **Step 13: Clean up temp files**
```bash
ssh jared@15.204.247.153 "rm /tmp/formbricks_app_data.tar.gz /tmp/formbricks_postgres_data.tar.gz 2>/dev/null; true"
rm /tmp/formbricks_app_data.tar.gz /tmp/formbricks_postgres_data.tar.gz 2>/dev/null; true
```
- [ ] **Step 14: Notify user — formbricks ready for DNS cutover**
Report: formbricks is running on OVH. User can now point `formbricks.hyperthrive.io` to 15.204.247.153. After DNS propagates, stop source: `ssh hyperthrive-docker "cd ~/services/formbricks && docker compose stop"`
---
## Post-Migration Checklist (after all DNS cutovers)
- [ ] Confirm each domain resolves to 15.204.247.153: `dig n8n.hyperthrive.io`, `dig testn8n.hyperthrive.io`, `dig formbricks.hyperthrive.io`
- [ ] Confirm HTTPS works and certs are valid for all three domains
- [ ] Log into n8n at https://n8n.hyperthrive.io and verify workflows and credentials are intact
- [ ] Log into formbricks at https://formbricks.hyperthrive.io and verify surveys/data are present
- [ ] Stop all three services on hyperthrive-docker once verified
- [ ] Update `docs/services-inventory.md` on OVH with the three new services