How to Run Your Own CI Checks Before Pushing to Production
Running your own CI checks before deploy means executing the same build, test and validation steps on your machine or in a pipeline that a production host would otherwise run for you, and refusing to ship when any of them fail. The goal of ci checks before deploy is simple: catch a broken build, a failing test, a missing environment variable or a bad config in a place where the blast radius is zero, rather than on the live site where customers are watching.
The mechanism is a gate. Your deploy path should have exactly one door, and that door only opens when the checks pass. Everything else is detail.
What ci checks before deploy actually verify
Before you automate anything, decide what "broken" means for your project. Most teams end up with four layers, and they run in this order because each one is cheaper than the next.
The first layer is a syntax or build check. For a compiled language this is the compiler or the bundler. For an interpreted one it is a linter or a type checker. A single command that exits non-zero on failure is enough. The second layer is the test suite. Unit tests first, then integration tests that touch a real database or a real queue. The third layer is a configuration validation pass: does the app parse its own config, are the required environment variables present, are the file paths writable. The fourth layer is a smoke test against a staging instance, which is the only layer that actually proves the thing runs.
The important property is not which tests you have. It is that the gate is mandatory and that it runs the same way on every machine. A check that only runs on your laptop is a habit, not a gate.
Running the gate locally with a pre-push hook
Git gives you a hook called pre-push. It runs after you type git push and before anything leaves your machine. If it exits non-zero, the push is aborted. This is the cheapest possible place to catch a mistake, because nothing has been uploaded and nothing has been deployed.
Create the file at .git/hooks/pre-push, make it executable with chmod +x, and put your checks inside it. Keep it fast. If the hook takes minutes, people will start passing --no-verify and the gate becomes decoration.
#!/bin/sh
set -e
npm run lint
npm test -- --runInBand
node scripts/validate-config.js .env.example
echo "pre-push checks passed"
Note the shape of the output. Each command prints its own summary, and the script only reaches the final line if all of them succeeded. If npm test exits with a failing test, set -e stops the script immediately and the push never happens. That is the whole trick.
The weakness of hooks is that they live in .git/hooks, which is not committed. A new clone has no hook. The usual fix is to keep the real script in the repository, for example at scripts/pre-push.sh, and have the hook simply call it. Then a documented setup step, or a small installer script, points the hook at the tracked file.
Moving the same checks into a real pipeline
Local hooks are bypassable and they only run on the machine of whoever remembered to install them. The pipeline is the authoritative gate. The rule to follow is that the pipeline runs the exact same commands as the hook, not a parallel set that drifts over time. Put them in one script and call that script from both places.
A minimal pipeline definition has three jobs in sequence: install, check, deploy. The deploy job must declare that it depends on the check job, and it must not run if the check job failed. Most CI systems express this with a dependency field. If your system does not, make the deploy step the last command of the same script, after the checks, so a failure short-circuits it.
deploy:
needs: [check]
if: success()
script:
- ./scripts/deploy.sh
Two details matter more than people expect. First, pin your dependency versions with a lockfile and install with the frozen or clean-install flag, so the pipeline builds the same tree you tested locally. Second, run the checks on a clean checkout, not on a working directory with uncommitted files, because that is the only way to know the commit itself is good.
Validating configuration and headers before they reach the host
A large share of production incidents are not code failures at all. They are configuration and header mistakes. A redirect loop, a missing Strict-Transport-Security header, a cache rule that serves stale HTML, a wrong Content-Type on a static asset. None of these show up in a unit test, so they need their own check.
One practical approach is to stand up the app locally or on a throwaway host and assert against the real responses. You can do this with a short script that checks status codes and header names, and it runs in seconds.
curl -sSI https://staging.example.com/ | grep -i '^strict-transport-security'
curl -sS -o /dev/null -w '%{http_code}\n' https://staging.example.com/health
curl -sSI https://staging.example.com/assets/app.css | grep -i '^content-type'
Run that against staging, never against the live host, and let it fail the build if a header is missing or a status code is unexpected. The -I flag asks for headers only, and -o /dev/null -w prints just the status code so the output is easy to assert on.
For the application's own configuration, validate it at startup and exit non-zero when something required is absent. A process that refuses to boot with a clear error is far better than one that boots and then throws on the first request. Check that required variables are set and non-empty, that the database URL parses, and that any writable directory is actually writable.
Making the gate trustworthy
A gate that fails for flaky reasons gets disabled, and a disabled gate is worse than no gate because it creates false confidence. Treat flakiness as a bug in the test, not as noise to retry away. Quarantine a genuinely flaky test into a separate non-blocking job and fix it, rather than adding a blanket retry that hides real failures.
Keep the total runtime short enough that people do not route around it. Split slow integration tests from fast unit tests and run the fast ones first, so the common failure is reported in seconds. Cache dependencies between runs so install time does not dominate. And make the failure output point at the cause: print the failing command, the exit code and the relevant log lines, not just "job failed".
Finally, make the gate the only path to production. If a human can deploy by hand without the checks running, the checks are advisory. If the deploy job literally cannot start until the check job succeeds, they are enforcement.
What to do next
Start with one command that fails loudly when your project is broken, put it in a tracked script, and wire that script into a pre-push hook today. Once it has caught a real mistake, move the same script into your pipeline and make the deploy step depend on it. Add the configuration and header checks after that, one at a time, each one only after you have confirmed it fails when it should. The order matters less than the habit of never letting a deploy path exist that skips the gate.
