- Server hardware failure or end-of-life
- Cloud provider migration
- Upgrading to a larger instance
- SSH access to both old and new servers as the
deployuser - DNS control for all app domains and the ops domain
- Backup encryption key (if encrypted backups are enabled)
- GitHub deploy key or SSH key for repo access on the new server
SSH into the old server and record what is running:
ssh deploy@<old-server-ip>List running apps and their deploy slots:
for dir in /opt/apps/*/; do
app=$(basename "$dir")
slot=$(cat "$dir/.deploy-slot" 2>/dev/null || echo "none")
echo "$app slot=$slot"
doneList app domains from the Caddyfile:
cat /opt/platform/CaddyfileRecord platform environment variables:
cat /opt/platform/.envList per-app credential files:
ls /opt/platform/credentials/List cron jobs:
crontab -lRun the backup script for every app database:
bash /opt/platform/infrastructure/backup-postgres.shVerify backups were created:
ls -lh /data/backups/postgres/# From your local machine:
scp -r deploy@<old-server-ip>:/data/backups/postgres/ ./migration-backups/
scp -r deploy@<old-server-ip>:/opt/platform/.env ./migration-platform.env
scp -r deploy@<old-server-ip>:/opt/platform/credentials/ ./migration-credentials/If encrypted backups are enabled, also copy the encryption key:
scp deploy@<old-server-ip>:<path-to-encryption-key> ./migration-backup-keyOn the new server, run the bootstrap script with the same env vars used for the old server:
sudo ACME_EMAIL=<your-email> OPS_DOMAIN=<ops.example.com> ALERT_REPO=<org/repo> \
bash infrastructure/bootstrap-server.shWait for all platform containers to become healthy:
docker ps --format "table {{.Names}}\t{{.Status}}"# From your local machine:
scp ./migration-platform.env deploy@<new-server-ip>:/opt/platform/.env
scp -r ./migration-credentials/ deploy@<new-server-ip>:/opt/platform/credentials/If using backup encryption, copy the key:
scp ./migration-backup-key deploy@<new-server-ip>:<path-to-encryption-key>Restart platform containers so they pick up the restored credentials:
ssh deploy@<new-server-ip>
cd /opt/platform
docker compose down && docker compose up -dCopy backup files to the new server:
# From your local machine:
scp -r ./migration-backups/ deploy@<new-server-ip>:/data/backups/postgres/On the new server, restore each app database:
ssh deploy@<new-server-ip>
bash /opt/platform/infrastructure/restore-postgres.sh --yes <backup-file>Verify each restored database:
bash /opt/platform/infrastructure/verify-backup.sh <database-name>For each app, clone the repo and set up the deploy directory:
cd /opt/apps
git clone git@github.com:towlion/<app-name>.git <app-name>
cd <app-name>Write the app's deploy/.env using credentials from /opt/platform/credentials/<app-name>:
cp deploy/env.template deploy/.env
# Edit deploy/.env with the correct DATABASE_URL, S3 credentials, JWT_SECRET, etc.Set the initial deploy slot:
echo "blue" > .deploy-slotRun the blue-green deploy script for each app:
bash /opt/platform/infrastructure/deploy-blue-green.sh \
<app-name> /opt/apps/<app-name> <app-domain> "<caddyfile-content>"Alternatively, trigger deploys via GitHub Actions once GitHub secrets are updated (step 12).
Check that all platform containers are healthy:
docker ps --format "table {{.Names}}\t{{.Status}}"Check health endpoints for each app (using the server IP directly, since DNS still points to the old server):
curl -sk --resolve <app-domain>:443:<new-server-ip> https://<app-domain>/healthVerify Grafana is accessible:
curl -sk --resolve <ops-domain>:443:<new-server-ip> https://<ops-domain>/Verify cron jobs are in place:
crontab -lUpdate A records for all domains to point to the new server IP:
- Each app domain (e.g.,
app.example.com,app2.example.com) - The ops domain (e.g.,
ops.example.com) - Preview wildcard records (e.g.,
*.preview.todo-app.example.com,*.preview.wit.example.com)
DNS propagation typically takes minutes but can take up to 48 hours depending on TTL. Consider lowering TTL values a day before the migration.
After DNS propagates, Caddy will automatically provision TLS certificates. Monitor the Caddy logs:
docker logs -f platform-caddy-1Test HTTPS on all domains:
curl -s https://<app-domain>/health
curl -s https://<ops-domain>/Verify certificates are valid:
echo | openssl s_client -connect <app-domain>:443 -servername <app-domain> 2>/dev/null | openssl x509 -noout -datesIn each app repository, update the following secrets to point to the new server:
SERVER_HOST— new server IPSERVER_SSH_KEY— SSH private key for the new server'sdeployuser
# Using the GitHub CLI:
gh secret set SERVER_HOST --repo towlion/<app-name> --body "<new-server-ip>"
gh secret set SERVER_SSH_KEY --repo towlion/<app-name> < ~/.ssh/<new-server-key>Trigger a test deploy on one app to confirm the pipeline works end-to-end.
Keep the old server running for 48-72 hours as a safety net. During this period:
- Monitor the new server for errors
- Confirm all deploys go to the new server
- Verify backups run successfully on the new server
Once satisfied, tear down the old server:
ssh deploy@<old-server-ip>
# Stop all containers
cd /opt/platform && docker compose down
for dir in /opt/apps/*/; do
app=$(basename "$dir")
docker compose -p "$app" -f "$dir/deploy/docker-compose.yml" down
doneThen delete or destroy the old server instance through your cloud provider.
If issues arise after the DNS switch:
- Revert DNS — Point A records back to the old server IP. The old server remains fully functional until explicitly decommissioned.
- Investigate — SSH into the new server and check logs, health endpoints, and container status.
- All platform containers healthy (
docker ps) - All app health endpoints return 200
- Grafana accessible at ops domain
- Backup cron running (
crontab -l) - GitHub Actions deploys targeting new server
- TLS certificates provisioned for all domains
- Preview environment DNS (wildcard record) updated
- Plan the migration during a low-traffic window to minimize impact.
- If you lower DNS TTL before migration, remember to restore it afterward.
- The old server's backups remain available as an additional safety net during the transition period.