How to Build a Deploy Gate: Pre-Push Smoke Testing That Actually Prevents Broken Deploys
You pushed to main. Vercel auto-deployed. Your site is down. The blog page throws `marked is not defined` because someone — or some*thing* — deleted a CDN script tag during a "cleanup" commit.
Sound familiar? If you've ever had an AI coding agent, a hasty refactor, or a late-night merge break production, you already know why pre-deploy smoke testing exists. The problem is that most teams treat it as a CI/CD afterthought — something that runs *after* the code is already committed and pushed.
This article covers how to build a **Deploy Gate**: a local, pre-push smoke test that boots your actual server, launches a headless browser, and validates that critical pages and DOM elements exist before `git push` is allowed to proceed. No cloud CI required. No waiting 5 minutes for GitHub Actions. Just a 15-second gate between your code and catastrophe.
## Why Pre-Push Beats Post-Push
The standard advice is "put tests in your CI/CD pipeline." That's correct. But it's also too late.
Here's the timeline of a typical broken deploy:
1. Developer commits a change
2. `git push origin main`
3. CI pipeline triggers (30-120 seconds to start)
4. Tests run (60-300 seconds)
5. Deploy happens anyway because the branch protection is misconfigured
6. Users see a broken page for 3-12 minutes
A pre-push gate catches the problem at step 2. The push is **blocked locally**. No commit reaches the remote. No CI pipeline wastes compute. No users see a 500 error.
The tradeoff? Your push takes 15 seconds longer. That's it.
## The Architecture
The Deploy Gate is surprisingly simple:
```
┌─────────────┐ ┌──────────────┐ ┌─────────────────┐
│ git push │────▶│ Deploy Gate │────▶│ Headless Chrome │
│ (blocked) │ │ (Node.js) │ │ (Puppeteer) │
└─────────────┘ └──────────────┘ └─────────────────┘
│
┌──────┴──────┐
│ Boot Server │
│ (Express) │
└─────────────┘
```
1. **Boot the actual server** on a test port (e.g., 3099)
2. **Launch headless Chromium** via Puppeteer
3. **Visit each critical page** and check for required DOM elements
4. **Report pass/fail** and exit with code 0 or 1
5. **Kill the server** regardless of outcome
If any check fails, the push is blocked with a clear error message telling you exactly what broke.
## Implementation
### The Config File
Define what to check in a JSON config:
```json
{
"server": {
"command": "node server.js",
"port": 3099,
"env": { "NODE_ENV": "development", "PORT": "3099" },
"startupTimeout": 15000
},
"checks": [
{
"name": "Homepage",
"path": "/",
"requiredElements": [],
"ignoreJsErrors": true
},
{
"name": "Blog Post Template",
"path": "/post/test-slug",
"requiredElements": [
"#post-content",
"#post-body",
"#share-twitter",
"#share-linkedin"
]
},
{
"name": "Tools Page",
"path": "/tools",
"requiredElements": []
}
]
}
```
Each check specifies a path and an array of CSS selectors that **must exist** in the rendered DOM. This is the key insight: you're not testing functionality, you're testing that the page renders at all and has the elements your JavaScript expects.
### The Gate Script
```javascript
const puppeteer = require('puppeteer-core');
const { spawn } = require('child_process');
const http = require('http');
async function runGate(config) {
// 1. Boot the server
const server = spawn('node', ['server.js'], {
cwd: process.cwd(),
env: { ...process.env, ...config.server.env },
stdio: 'pipe'
});
// 2. Wait for server to respond
await waitForServer(config.server.port, config.server.startupTimeout);
// 3. Launch headless browser
const browser = await puppeteer.launch({
executablePath: '/usr/bin/chromium-browser',
headless: true,
args: ['--no-sandbox', '--disable-gpu']
});
let allPassed = true;
for (const check of config.checks) {
const page = await browser.newPage();
const url = `http://localhost:${config.server.port}${check.path}`;
try {
const response = await page.goto(url, {
waitUntil: 'networkidle0',
timeout: 10000
});
// Check HTTP status
if (response.status() >= 500) {
console.error(`❌ ${check.name}: HTTP ${response.status()}`);
allPassed = false;
continue;
}
// Check required DOM elements
for (const selector of check.requiredElements) {
const el = await page.$(selector);
if (!el) {
console.error(`❌ ${check.name}: Missing ${selector}`);
allPassed = false;
}
}
if (allPassed) console.log(`✅ ${check.name}: PASS`);
} catch (err) {
console.error(`❌ ${check.name}: ${err.message}`);
allPassed = false;
} finally {
await page.close();
}
}
await browser.close();
server.kill();
process.exit(allPassed ? 0 : 1);
}
```
### Wiring It to Git
Create a `.git/hooks/pre-push` file:
```bash
#!/bin/bash
echo "🚦 Running Deploy Gate..."
node scripts/deploy-gate.js --config deploy-gate.config.json
if [ $? -ne 0 ]; then
echo "❌ Deploy Gate FAILED — push blocked"
exit 1
fi
echo "✅ Deploy Gate passed — pushing"
```
Make it executable: `chmod +x .git/hooks/pre-push`
Now every `git push` runs the gate first.
## What This Catches
In practice, this catches an embarrassing number of real-world breakages:
- **Deleted CDN scripts** — An AI agent "cleaning up" your HTML removes the Marked.js or Prism.js `<script>` tag. The gate catches it because `#post-body` never gets populated.
- **Missing DOM elements** — A refactor removes `<div id="share-twitter">` but the JavaScript still calls `getElementById('share-twitter')`. Gate catches the missing element.
- **Server crashes on boot** — A bad `require()` path or missing environment variable crashes Express before it can serve any page. Gate catches the startup failure.
- **Broken route handlers** — A middleware change returns 500 on a previously working route. Gate catches the HTTP status.
- **Template rendering failures** — SSR injects content into a template, but someone deleted the injection target. Gate catches the empty page.
## Real-World Example: The AI Agent Incident
Here's what actually happened to us. We had three AI sub-agents running in parallel to improve our blog:
1. **SEO Agent** — optimizing metadata
2. **Blog Post Agent** — improving the article template
3. **Performance Agent** — cleaning up unused code
The Blog Post Agent decided our `blog-post.html` had "unnecessary scripts" and removed:
```html
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
```
Meanwhile, the SEO Agent overwrote `server.js` — a 673-line Express server — with a 17-line fragment.
Both changes were committed. Both were pushed. The entire site went down.
After emergency-reverting from git history, we built the Deploy Gate. It now runs before every push and checks that:
- The server actually boots
- Blog posts render with `#post-body` and `#post-content` present
- Share buttons exist (`#share-twitter`, `#share-linkedin`, etc.)
- Critical pages return HTTP 200
Had the gate existed before the incident, **both pushes would have been blocked**.
## Performance Considerations
On a VPS with 2GB RAM:
| Metric | Value |
|--------|-------|
| Peak RAM | ~150-200 MB |
| Runtime | ~15 seconds |
| Chromium startup | ~3 seconds |
| Per-page check | ~2 seconds |
| Server boot | ~2 seconds |
This is lightweight enough to run on every push without being annoying. Compare that to a full CI pipeline that spins up Docker containers and takes 2-5 minutes.
## Tips for Production Use
**Use `puppeteer-core` instead of `puppeteer`**. The full `puppeteer` package downloads its own Chromium (~300MB). Use `puppeteer-core` and point it at your system's existing Chromium installation. Saves disk space and download time.
**Tolerate missing databases**. If your app uses MongoDB or PostgreSQL, the gate should still pass when the database is unavailable. Check that the server *responds* (even with a fallback page), not that it returns perfect data. In our setup, we accept any HTTP response as "server is up" since MongoDB isn't available in the local dev environment.
**Don't test business logic**. The gate tests *structural integrity*, not functionality. "Does the page render?" not "Does the login form work?" Keep checks fast and focused. Save integration tests for CI.
**Filter JavaScript errors intelligently**. Some pages throw benign JS errors (e.g., analytics scripts failing without network). Use an `ignoreJsErrors` flag per check rather than failing on every console error.
**Run it as a skill, not a script**. If you're using an AI agent framework like OpenClaw, package your gate as a reusable skill with a config file. The agent can run it automatically before every deploy, and you get a clean separation between the gate logic and your project code.
## The Guardrail Principle
The Deploy Gate is part of a broader pattern we call **subagent guardrails**. When you have AI agents modifying your codebase — whether it's Cursor, Copilot, Codex, or multi-agent systems like OpenClaw — you need automated safeguards that are immune to the agents' enthusiasm.
The principle is simple: **never trust any code change that hasn't been validated by an independent, automated check.** The AI agent thinks it's "improving" your code. The Deploy Gate doesn't care what the agent thinks. It only cares whether the site still works.
Other guardrails worth implementing:
- **Max diff percentage** — block commits that change more than 50% of a critical file
- **CDN dependency validation** — verify all script/link tags still reference valid CDN URLs
- **AST-based HTML validation** — parse HTML templates and verify structural integrity before accepting changes
- **Rollback automation** — auto-revert if post-deploy health checks fail
## Conclusion
A Deploy Gate is 100 lines of JavaScript that sits between your code and production. It boots your server, renders your pages in a real browser, and checks that the DOM elements your application depends on actually exist.
It won't catch every bug. It won't replace your test suite. But it will catch the catastrophic, site-down, "who deleted the script tag" failures that are somehow the most common breakages in modern web development.
Build time: 30 minutes. Saves you: every future 3 AM "the site is down" incident.
The code is open source and available as an [OpenClaw skill](https://stormap.ai/tools) you can install in your own workspace.