Hosting static sites at home with a webhook deploy
Most homelab services get a lot of hand-waving about “it’s just a Docker container.” Static file hosting is the one place that hand-wave is literally correct. There is no database, no authentication layer, no background worker eating memory. It’s a web server pointing at a folder. That simplicity makes it a great first self-hosted service — and once you add a webhook deploy loop, it punches well above its weight.
What we’re actually talking about
A static site is any collection of files your browser can render directly: HTML, CSS, JavaScript, images, PDFs, SVG diagrams. There’s no server-side code running per-request. The server’s only job is to hand files to browsers as fast as possible, which it can do basically forever with near-zero CPU.
Static hosting is older than the modern web. What makes it interesting again in 2025–2026 is the combination of:
- Lightweight tooling that can serve files from a container using almost no resources
- Git as the source of truth for the files being served
- Webhooks that connect a git push to an automatic redeploy in seconds
Put those three together and you have a pipeline that feels like cloud hosting, running entirely on hardware you own.
The problem it solves
The specific itch here: internal documentation that should be browsable from any device on the network, without requiring a login, a SaaS subscription, or the right app installed. Think network diagrams, runbooks, reference HTML files, or anything you’d otherwise dump on a shared drive and then never be able to find again.
The naive solution is to just put files somewhere and mount the share. That works, but it has friction — you need a file manager, you have to know where the share is, it doesn’t render HTML nicely, and it doesn’t version-control well. A proper HTTP server solves all of that: any browser, any device, immediate rendering, no special client needed.
The second problem is the deploy loop. If updating a page requires you to SSH somewhere, pull a repo, and restart a service, you will not bother keeping it up to date. The whole value of a docs site is that it stays current. Automating the deploy turns “update the diagram” from a multi-step chore into git push.
What the cloud gives you — and why it may not be enough
Netlify, GitHub Pages, Cloudflare Pages, and S3 static hosting are all genuinely excellent. For a public site, they’re hard to beat: free tiers are generous, global CDN delivery is fast, and the deploy pipeline is already built for you. If you’re serving a public blog or marketing site, there’s a real argument for just using them.
The gap appears when you want to serve private or internal content — documentation that shouldn’t be indexed, files you’d rather not upload to a third-party CDN, diagrams that include internal topology. You can use private repositories and access tokens to paper over this, but you’re now adding credentials and third-party access to content you’d rather keep local. For internal lab documentation in particular, self-hosting is the cleaner answer.
Cost is a secondary consideration. Static hosting on the major platforms is cheap or free for reasonable volumes. The argument isn’t primarily financial — it’s about where your data lives and how many external dependencies your internal tooling has.
Self-hosted options worth knowing
There are several ways to approach this. The tradeoffs come down to simplicity versus features.
nginx or Caddy as a pure static server is the simplest path. Both will serve a directory over HTTP with a handful of config lines. Caddy has the advantage of automatic HTTPS (which matters if you’re proxying it publicly), a very clean config syntax, and sensible defaults. nginx is more battle-tested at scale, has a smaller container image, and has more documentation floating around on the internet. For purely internal use behind a reverse proxy that’s already handling TLS, the difference is minor — pick whichever you’re more comfortable with.
Caddy with a file server block is one config stanza:
file_server {
root /srv/files
}
That’s it. Browse to the address and you get a directory listing. Point it at a git checkout and you have your deploy target.
nginx:alpine is a container image that weighs in at a few megabytes and idles at nearly nothing. A compose file with a single volume mount and you’re done. It doesn’t auto-index as prettily out of the box, but for serving known files rather than browsing a directory, it’s perfectly fine.
Full CI runners (Woodpecker CI, Drone, Gitea Actions) are the heavier option. You get a full pipeline, test steps, build stages, and artifact handling. For a static site that’s just files — no build step, no npm — this is overkill. It’s the right answer if you’re generating a site with a static site generator like Hugo, Eleventy, or Jekyll, where there’s a build step between “source files” and “served HTML.” For files-as-files, a simpler webhook approach wins on resource usage and complexity.
adnanh/webhook is a small Go binary that listens for incoming HTTP POST requests and runs a shell script in response. It’s the minimal bridge between “git server fires a hook on push” and “server pulls the new files.” No CI runner, no pipeline YAML, no build agents — just a webhook receiver and a git pull.
How this fits together in the lab
The setup in production here is deliberately minimal: an nginx container serving static files from a directory, and a webhook sidecar container that runs a pull script whenever the source repository gets a push.
The git repository holds the files. The webhook receiver listens for push events from the self-hosted git server, verifies the request against an HMAC signature (so random internet traffic can’t trigger deploys), and runs a script that does a git fetch followed by a hard reset to the latest commit. nginx is serving straight from the same directory, so the update is live immediately — no restart, no cache invalidation, just fresh files.
One detail worth noting from the build: the commonly-linked webhook Docker images don’t include git, which is a quiet gotcha. If you want a container that can both receive the webhook and run git commands, you need a custom image — or a multi-stage build that layers git onto the webhook binary. It’s not hard, but it’s the kind of thing that burns time if you don’t know to expect it.
The webhook payload is verified with HMAC-SHA256 against a shared secret stored in the credential manager, not hardcoded in the compose file. This is worth doing even on an internal service — you probably don’t want any machine on your network able to trigger a redeploy just by knowing the URL.
The whole stack uses minimal resources. Two containers: one for the web server, one for the webhook receiver. Combined, they idle at a few megabytes of RAM. This is the kind of service you can run on the same host as several other containers without thinking about it.
The deploy flow in practice
Day-to-day usage looks like:
- Edit a file on your workstation — a diagram, a runbook page, a reference document.
- Commit and push to the git repository.
- The git server fires a webhook POST to the deploy listener.
- The listener verifies the signature, runs the pull script, done.
- Reload the browser. The updated file is there, typically within a few seconds of the push.
That turnaround means you’ll actually keep documentation current. The feedback loop is tight enough that updating a page feels cheap. Compare that to a shared drive workflow where “update the network diagram” involves opening a specific app, finding the right mount, editing, saving, and then nobody knows where the latest version is — and the self-hosted approach wins on user experience, not just principle.
Who should bother
Good fit if:
- You have internal documentation, diagrams, or reference files that you want browsable from any device on your network.
- You already have a self-hosted git server (Gitea, Forgejo, or similar) and want to close the loop from push to publish.
- You want to understand how CD pipelines work at the smallest possible scale before tackling something more complex.
- You’re already running a reverse proxy and want to add a new backend with minimal overhead.
Probably skip it if:
- All your docs live in a wiki (Outline, BookStack, Wikijs) that already has its own editor and access control — serving raw HTML alongside a wiki is duplication.
- Your content needs authentication per-user or fine-grained permissions. Static files served from nginx have no concept of users. If some docs are private to some people, you need a different layer — a reverse proxy with auth headers, or a service that handles auth natively.
- You need a build step (a static site generator, Sass compilation, etc.) and want proper cache, incremental builds, and artifact storage. At that point a lightweight CI runner like Woodpecker is worth the setup investment.
The broader pattern
What’s actually interesting about this service isn’t the static file part — it’s the webhook-driven deploy. That pattern generalizes:
- A configuration file repository where a push automatically applies the new config.
- A dashboard or report that regenerates from source data on commit.
- A documentation site built with Hugo where the CI runner replaces the webhook but the push-to-live loop is the same.
Once you’ve wired up one webhook deploy, the shape of the problem is familiar. The git repository becomes the authoritative source; the running system is always a reflection of what’s in the repo. That’s the beginning of treating your homelab with infrastructure-as-code discipline — and it starts here, with two containers and a shell script that runs git pull.
Closing
A self-hosted static file server is about as unglamorous as homelab services get. No AI, no dashboards, no clever integrations. Just a web server pointing at a folder. But the webhook deploy loop turns it into something genuinely useful — a publish pipeline you own and understand completely, with no third-party in the middle.
If you’ve ever caught yourself thinking “I should document this better but I never update the docs,” this is the kind of friction reducer that actually changes the habit. Push a file, it’s live. That’s the whole pitch.
Next up: the reverse proxy that sits in front of all of this and handles the TLS termination.
Comments
No comments yet — be the first.