Every infrastructure team eventually faces the same moment: the current server has to go. Maybe it's end-of-life hardware, a provider change, an OS that's fallen out of support, or, in my case recently, a security incident that made "we'll migrate eventually" turn into "we're migrating this weekend."
That kind of pressure is exactly when teams make the mistake I want to talk about: treating a migration as a copy-paste job. Pull the old files, restore the old database, point DNS at the new IP, done. It feels efficient. It's actually the worst possible time to do that, because you're about to spend a weekend rebuilding a server from scratch, which is the single best opportunity you'll get all year to fix everything you've been meaning to fix.
Here's the phased approach I've settled on after running this exact process across several production applications at once.
Phase 1: Rotate everything before you touch the new server
Before any provisioning happens, generate fresh secrets for every credential the old server held: database passwords, session/signing keys, API keys, service-to-service tokens. Write each one straight into a password manager as you generate it, labeled clearly. This step is boring and easy to skip under time pressure, which is exactly why it causes the most regret when skipped: an old secret quietly copied into the new .env file defeats the entire point of migrating.
If multiple applications share a secret today, this is also the moment to ask whether they should. Shared signing keys across apps are a convenience that becomes a liability the moment one app is compromised, so give each application its own.
Phase 2: Back up like you'll actually need to restore
Don't just archive the database. Confirm the full list of databases that exist on the box (it's surprising how often there's a stale one nobody remembers), archive uploaded files separately, and verify file counts and sizes against what you expect before you consider the backup complete. A backup you haven't verified isn't a backup, it's a hope.
Phase 3: Provision the new server hardened from minute one
Pick a stable, supported OS version rather than the newest release: new servers are not the place to also be the first to hit an ecosystem's compatibility issues. Add your SSH key during provisioning so password auth is never enabled in the first place, and configure the network-level firewall before the server has a public-facing service running on it. Order matters here: every rule should exist before the thing it protects goes live, not after.
Phase 4: Harden application by application, not once for everything
This is the phase teams most often shortcut, because it feels repetitive across multiple apps. It's also where most real vulnerabilities hide. For each application on the box:
- Give it its own environment file and its own secrets, never a shared
.env - Set a sane request body size limit so large uploads can't be used to exhaust disk or memory
- Validate and scan anything user-uploaded, including "safe" formats: SVGs and similar file types can carry executable content disguised as static assets
- Turn off debug/verbose error modes in production; a stack trace is a map for an attacker
- Exclude test scripts and database-reset utilities from the production deployment entirely: they're useful in development and dangerous in production
Phase 5: Get the reverse proxy and TLS right
At the Nginx (or equivalent) layer: add standard security headers across every site, add rate limiting and connection limiting so a single client can't hammer the box, and set timeouts that protect against slow-connection abuse. Once certificates are issued, don't assume auto-renewal will just work: actually run a renewal dry-run and confirm it succeeds before you need it to.
Phase 6: Automate the boring security work
Unattended security updates, log rotation, and basic monitoring should be configured before go-live, not added later "when there's time." The whole point of hardening a fresh server is that it stays hardened without someone remembering to do it manually every month.
Phase 7: Cut over only when your checklist actually passes
DNS cutover should be a gate, not a deadline. Walk through every check (services running, TLS valid, firewall rules active, backups confirmed) and only move traffic once all of them pass. A migration that goes live on schedule but skips a check isn't actually finished; it's just live and unfinished.
Phase 8: Keep a list for what's "real but not blocking"
Not everything you find during a hardening pass needs to stop the migration. Some issues, like a legacy page needing better output escaping, a Swagger/API docs endpoint reachable without auth, or a dependency with a moderate advisory, are worth fixing but not worth holding go-live for. The mistake isn't deprioritizing them; it's not writing them down. Keep an explicit follow-up list with an owner and a rough timeline, or they'll quietly become permanent.
The actual lesson
A forced migration is annoying. It's also the most leverage you'll ever have to fix the things that were always slightly wrong: the shared secret nobody rotated, the upload handler nobody scanned, the security header nobody added. The playbook above isn't really about moving servers. It's about not wasting the one moment your infrastructure is fully torn down and rebuildable.
If you're facing a migration and want a second pair of eyes on the plan, or the audit that should happen before it, that's exactly the kind of work we do at City Project Solutions.
