AWS to Azure Government migration: the small things that break
We moved a Next.js platform from AWS to Azure Government. None of the big pieces were the problem in the AWS to Azure migration. Four small ones were.
From 2026 · 2 min read · Published August 10, 2026
This year we migrated a client's production platform (Next.js, Postgres, object storage, a React Native app) from AWS to an Azure Government cloud region, with infrastructure in Pulumi and deploys through GitHub Actions using federated OIDC.
The big pieces went roughly to plan. What cost us time were four small things, each of which worked fine in one environment and failed in another. They're all generic enough to be worth writing down.
1. The password that broke the URL
Pulumi generated a random database password, and we interpolated it into a DATABASE_URL. In dev, everything worked. In production, every request that touched the database returned a 500.
The production password happened to contain % followed by letters. Inside a URL, that looks like a broken percent-escape, and Prisma's URL parser threw URIError: URI malformed. The dev password just happened to have no problematic characters.
const databaseUrl = pulumi.interpolate`postgresql://${user}:${password.result.apply(encodeURIComponent)}@${host}:5432/${db}?sslmode=require`Always URL-encode credentials you put in a URL, especially generated ones. A random password will eventually contain every character you forgot about.
2. The header S3 never needed
Avatar and logo uploads went straight from the browser to a signed URL. On S3, a plain PUT works. On Azure Blob Storage, the same request fails with a 400: "An HTTP header that's mandatory for this request is not specified."
await fetch(signedUrl, {
method: 'PUT',
body: imageData,
headers: {
'Content-Type': 'image/png',
'x-ms-blob-type': 'BlockBlob',
},
})Signed upload URLs look portable. The requests against them aren't.
3. The firewall rule that existed but didn't work yet
CI opens a temporary Postgres firewall rule for the runner, applies the schema, then closes it. We polled the rule until the API confirmed it existed, then connected. Sometimes it worked, sometimes it failed with "can't reach database server."
The control plane records the rule before the database gateway enforces it. A successful read proves the rule exists, not that the database is reachable. One run with a 12-second gap failed; another with 26 seconds succeeded.
The fix is to retry the thing you actually care about:
for attempt in 1 2 3 4 5 6; do
if pnpm prisma db push --skip-generate; then exit 0; fi
echo "attempt $attempt failed; database may not be reachable yet"
sleep 10
done
exit 14. The flaky secret fetch
Key Vault in the government region intermittently rejected fresh OIDC tokens because the signing key hadn't propagated yet. It killed four CI runs in one day. A short retry with linear backoff on every step that reads a secret made it a non-issue.
The pattern in every AWS to Azure migration
Every one of these is the same lesson: the thing you checked isn't the thing you needed. The URL looked valid, the signed URL looked portable, the firewall rule looked created, the token looked issued. When moving between clouds, verify the end-to-end operation, and retry the operation itself rather than a proxy for it.
Related: Data migration testing: when the oracle is wrong · Wait for the CloudFront invalidation: deploying a SPA to S3