Pin every tool, image and CLI to a version
Commit the lockfile and install with npm ci, which fails on a mismatch instead of updating it. Pin the Node version in CI, pin deploy CLIs to a major version, and reference base images by digest, as the Dockerfile reference allows with <image>@<digest>.
npm ci
npx vercel@50 --version # pin the CLI major you tested, not 'latest'
Make every script fail loudly
Set the shell explicitly so GitHub Actions uses bash --noprofile --norc -eo pipefail {0}, and start your own scripts with strict flags. Check the exit code of the deploy command itself, not the exit code of the last command in the script.
set -euo pipefail # at the top of every deploy script
# in the workflow: defaults: { run: { shell: bash } }
Build into a new directory and swap it in only on success
Don't delete the live output until the new build succeeds. Build elsewhere, check that the output is non-empty, then rename. If your host does atomic deploys, use them instead of copying files into place by hand. The example uses Vite's --outDir.
set -euo pipefail
npm run build -- --outDir dist.new
test -s dist.new/index.html
rm -rf dist.prev
[ -d dist ] && mv dist dist.prev
mv dist.new dist
Run migrations before the code that needs them, in the same pipeline
Use the host's pre-deploy hook so a failed migration fails the whole deploy. On Render, the pre-deploy command runs after the build and before the deploy; if it fails or times out, the deploy fails and the service keeps running its last successful deploy. Write migrations so the old code still works against the new schema, and deploy backend functions before the frontend that calls them.
Add a health check that the new version must pass
Railway queries your health-check path until it gets a 2xx status and marks the deploy failed if that does not happen within the timeout (300 seconds by default). It runs only at deploy time, so it does not replace monitoring. Have the endpoint do real work, such as a database read, instead of returning 200 unconditionally.
End every deploy by requesting the real production URL
Publish a small version file at build time and have the last pipeline step fetch it from the public domain, as a visitor would. --fail-with-body makes curl exit with code 22 on a 400 or higher status and still print the body. Make that step fail the job and alert on it.
curl --fail-with-body --silent --show-error --max-time 20 \
https://example.com/version.json | grep -q "$GIT_SHA"
Know your rollback and skew settings before you need them
Vercel Instant Rollback points domains back to an earlier production deployment; you must promote a deployment afterwards to restore auto-assignment. Cloudflare Pages treats any successfully built production deployment as a rollback target. To stop chunk load errors during releases, enable Vercel Skew Protection or set Next.js deploymentId, so a client keeps talking to the deployment that served its page.
// next.config.js
module.exports = { deploymentId: process.env.GIT_HASH }