Skip to content

cplieger/docker-static-web

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

49 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

docker-static-web

License: GPL-3.0 GitHub release Image Size Platforms base: scratch

A static file server in ~30 KB: darkhttpd compiled statically with hardening flags, UPX-compressed, on scratch.

What it does

Serves static files over HTTP. That's it. The image is essentially a single ~30 KB binary on a scratch base — no shell, no libc, no package manager, no auth, no TLS, no fancy config. Mount your document root at /www and darkhttpd serves it on port 8567.

This is the smallest viable container for serving static content. Use it for:

  • Internal-only static sites behind a reverse proxy (which handles TLS, auth, and rate-limiting)
  • Health-check landing pages
  • /.well-known/ ACME challenges when you need a separate webroot
  • CI/test fixtures where you want to serve files without spinning up nginx

Why this design

  • Scratch base — image is essentially the binary itself, ~30 KB compressed. No shell to exec, no package manager to attack
  • UPX-compressed binary — runtime memory is tiny; the binary unpacks itself in-process
  • Hardening flags-D_FORTIFY_SOURCE=2, -fstack-clash-protection, -fstack-protector-strong, RELRO + BIND_NOW + NOEXEC stack at link time
  • Static linking--static so the binary has zero dependencies, runs identically on amd64 and arm64
  • Sane darkhttpd defaults--maxconn 128 --no-listing --no-server-id: connection cap to limit DoS impact, no directory indexes, no Server: header leaking the version
  • Tarball integrity check — Dockerfile pins DARKHTTPD_SHA256 so a tampered tarball fails the build

Quick start

services:
  static-web:
    image: ghcr.io/cplieger/docker-static-web:latest
    container_name: static-web
    restart: unless-stopped

    ports:
      - "8567:8567"

    # Mount your document root read-only.
    volumes:
      - ./www:/www:ro

Behind a reverse proxy (e.g. Caddy):

static.example.com {
    reverse_proxy static-web:8567
}

Configuration reference

Volumes

Mount Description
/www Document root. Mount read-only. darkhttpd serves files from here.

Ports

Port Protocol Purpose
8567 TCP HTTP — change in command: if you want a different port

Default command

The Dockerfile's CMD is:

CMD [".", "--port", "8567", "--maxconn", "128", "--no-listing", "--no-server-id"]

Override the entire command if you want different darkhttpd flags:

services:
  static-web:
    image: ghcr.io/cplieger/docker-static-web:latest
    command: [".", "--port", "80", "--maxconn", "256", "--no-listing"]
    ports:
      - "80:80"
    volumes:
      - ./www:/www:ro

See darkhttpd --help for all available flags.

Healthcheck

No built-in healthcheck. The scratch base has no shell, no wget, no curl, no nc. Docker can't run a healthcheck inside the container without one of those.

For external monitoring, use Uptime Kuma, Prometheus blackbox exporter, or any other off-host probe:

curl -sf http://your-host:8567/ -o /dev/null && echo OK

If you really need a Docker-level healthcheck, the typical pattern is to run a sidecar that hits the static-web container — but for most homelab uses, an external HTTP probe is simpler and more meaningful (it verifies the network path too).

What it doesn't do

  • No TLS — put it behind a reverse proxy that terminates HTTPS
  • No auth — same; let your reverse proxy handle access control
  • No directory listings — disabled by default (--no-listing)
  • No CGI / dynamic content — it's a static file server
  • No HTTP/2 or HTTP/3 — HTTP/1.1 only; let your reverse proxy upgrade the public-facing connection

If you need any of these, use Caddy / nginx / a real web server.

Security

Tool Result
hadolint Clean
gitleaks No secrets detected
trivy 0 vulnerabilities (scratch base, no OS packages to scan)

The image is published with cosign signatures and SBOM attestations.

The build pins DARKHTTPD_SHA256 and verifies the upstream tarball before extracting. When Renovate bumps DARKHTTPD_VERSION, you must manually update DARKHTTPD_SHA256 in the same PR — Renovate is configured to require manual approval for darkhttpd bumps for exactly this reason. Compute the new hash with:

curl -sL https://github.com/emikulic/darkhttpd/archive/refs/tags/v<N>.tar.gz | sha256sum

Image size

The image is roughly 30 KB compressed (see the badge at the top). The binary is the entire image — there's no Alpine layer, no /etc, no /lib, nothing else. docker run --rm ghcr.io/cplieger/docker-static-web ls / won't work (no ls in scratch).

Dependencies

All dependencies are updated automatically via Renovate and pinned by digest or version for reproducibility.

Dependency Version Source
alpine (builder) 3.23.4 Docker Hub
build-base 0.5-r3 (Alpine 3.23 meta-package) Alpine
upx 5.0.2-r0 (Alpine 3.23 package) Alpine
darkhttpd v1.17 GitHub

Credits

This project packages darkhttpd by @emikulic into a scratch-based container. All credit for the web server itself goes to the upstream maintainer — darkhttpd has been "small, secure, and fast" since 2003.

Contributing

Issues and pull requests are welcome. Please open an issue first for larger changes so the approach can be discussed before implementation.

Disclaimer

This image is built with care and follows security best practices, but it is intended for homelab use. No guarantees of fitness for production environments. Use at your own risk.

This project was built with AI-assisted tooling using Claude Opus and Kiro. The human maintainer defines architecture, supervises implementation, and makes all final decisions.

License

This project is licensed under the GNU General Public License v3.0.

About

Tiny static file server in ~30 KB — darkhttpd compiled statically with hardening flags, on a scratch base

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors