I work on a bunch of 11ty websites—mine and some client projects. Getting feedback on work-in-progress branches is annoying when it means building locally and describing changes over email. So I set up a system that gives every branch a live preview URL.
Push a branch, and a few seconds later it's live at something like my-feature.nbld.io. It works great, and honestly it's pretty simple to set up. I'm running it on my home server since my fiber is fast enough for this kind of light traffic.
The Solution: GitHub Webhooks + Node.js + Nginx + Wildcard DNS
The system has four main components:
- GitHub Webhooks - detect push and delete events
- Webhook listener (Node.js) - handle deployments
- Nginx - reverse proxy with per-subdomain configs
- Wildcard DNS -
*.nbld.iopoints to one IP
Architecture
GitHub Push Event
↓
Webhook Listener (Node.js)
↓
├─→ Build (git checkout, npm build)
├─→ Deploy (copy _site to /mnt/nginx/{slug}.nbld.io)
└─→ Configure Nginx
↓
Subdomain Live (https://feature-name.nbld.io)
Setting Up
1. Wildcard DNS
Set up a wildcard DNS record pointing to your server:
* A <your-server-ip>
This makes anything.yourdomain.com resolve to your server. Pair it with a wildcard certificate (*.yourdomain.com) to handle SSL for all subdomains.
2. The Webhook Listener
I wrote a Node.js HTTP server that:
- Listens for GitHub webhooks on
/webhook - Verifies signatures (security)
- Clones/pulls the repo
- Runs the build (
npm run build) - Generates an Nginx config
- Reloads Nginx
Key parts:
Slug generation - branch names become DNS-safe:
function slugify(name) {
let slug = name.replace(/[^a-z0-9-]/gi, '-').toLowerCase();
if (slug.length > 63) slug = slug.substring(0, 63);
return slug;
}
Nginx config generation - each branch gets its own server block:
server {
listen 443 ssl http2;
server_name feature-name.yourdomain.com;
ssl_certificate /path/to/wildcard-cert/fullchain.pem;
ssl_certificate_key /path/to/wildcard-cert/privkey.pem;
root /deploy/path/feature-name.yourdomain.com;
index index.html;
error_page 404 /404.html;
location / {
try_files $uri $uri/ =404;
}
}
3. Deployment Flow
async function deployBranch(repoCwd, slug) {
const domainName = `${slug}.yourdomain.com`;
const deployPath = `/deploy/path/${domainName}`;
// 1. Build
executeCommand('git fetch origin', repoCwd);
executeCommand(`git checkout ${slug}`, repoCwd);
executeCommand('git reset --hard origin/${slug}', repoCwd);
executeCommand('npm ci', repoCwd);
executeCommand('npm run build', repoCwd);
// 2. Deploy
executeCommand(`mkdir -p ${deployPath}`);
executeCommand(`cp -r ${repoCwd}/_site/* ${deployPath}/`);
// 3. Configure Nginx
fs.writeFileSync(`/tmp/${domainName}.conf`, generateNginxConfig(slug));
executeCommand(`sudo cp /tmp/${domainName}.conf /etc/nginx/sites-available/`);
executeCommand(`sudo ln -sf /etc/nginx/sites-available/${domainName} /etc/nginx/sites-enabled/`);
executeCommand(`sudo nginx -t`);
executeCommand(`sudo systemctl reload nginx`);
}
4. Cleanup
When a branch is deleted:
async function deleteBranch(branchName) {
const slug = slugify(branchName);
const domainName = `${slug}.yourdomain.com`;
const deployPath = `/deploy/path/${domainName}`;
// Remove Nginx config
executeCommand(`sudo rm /etc/nginx/sites-enabled/${domainName}`);
executeCommand(`sudo rm /etc/nginx/sites-available/${domainName}`);
executeCommand(`sudo systemctl reload nginx`);
// Remove deployed files
fs.rmSync(deployPath, { recursive: true });
}
Repository Routing
Here's where it gets useful. I can throw different projects at this and they route differently:
My own repo (nbld.io):
- Push to
main→ deploys to production atnbld.io - Push to any other branch → preview at
feature-name.nbld.io
So if I'm working on a redesign in a redesign branch, it's live at redesign.nbld.io while the main site stays untouched.
Client projects (e.g., a client's website repo):
- Push to
main→ ignored (they deploy to their own domain) - Push to any branch → preview at
website-branch-name.nbld.io
This way I can spin up previews for client work without touching their production. They can see the work in progress without managing their own deployment infrastructure. And when they're done, they push to their own domain. My infrastructure is just the staging hub.
Security Notes
I verify GitHub's webhook signatures with HMAC-SHA256, so I know the requests are actually from GitHub. The webhook listener runs as a limited deploy user, and every nginx config gets tested before reload. Nothing fancy, but it covers the basics.
I keep secrets in environment variables, not in code. The usual stuff.
That's It
So yeah, now when I push a branch it just... lives somewhere. Clients can click a link and see the work in progress. No more "hold on, let me build this locally and describe it to you."
It's been running on my home server for a while now and honestly it just works. The wildcard cert handles all the subdomains, and the simple routing means I can throw multiple projects at it without any extra setup.
If something breaks or a branch gets messy, it just disappears when I delete the branch. No cleanup worries.