Pular para o conteúdo principal

Static http server

Build Status Opensource ByJG GitHub source GitHub license GitHub release

A really minimal HTTP/HTTPS Server image for static files written in Go.

Why?

  • Create a simple HTML website
  • Serve static files with HTTP and HTTPS (self-signed certificate generated and reused automatically)
  • SPA (Single Page Application) support for frontend frameworks like React, Angular, Vue
  • In-memory LRU file cache with configurable limits
  • Health check endpoint for Kubernetes probes
  • Really small footprint

How to use the "Parking page"?

The image includes a self-contained parking page (single HTML file, no external dependencies) that can be customized by setting the environment variables:

  • HTML_TITLE - Page title (default: "Coming soon")
  • TITLE - Main heading (default: "soon")
  • MESSAGE - Body message
  • BG_IMAGE - Background image URL
  • FACEBOOK - Facebook page URL
  • TWITTER - Twitter page URL
  • YOUTUBE - YouTube page URL

e.g.

docker run -p 8080:8080 -p 8443:8443 -e TITLE=soon -e "MESSAGE=Keep In Touch" byjg/static-httpserver

Configuration

The server can be configured via CLI flags or environment variables. CLI flags take precedence over environment variables.

CLI FlagEnv VariableDefaultDescription
--root-dirROOT_DIR(required)Root directory for static files
--bindBIND_ADDRESS0.0.0.0IP address to listen on
--only-httpsONLY_HTTPSfalseNever start the HTTP listener, even when a port is set
--portPORT(disabled)HTTP listening port. Not set = HTTP disabled
--tls-portTLS_PORT8443HTTPS listening port
--tls-cert-dirTLS_CERT_DIR(see below)Directory to look for cert.pem and key.pem, and where the self-signed certificate is saved
--tls-cert-fileTLS_CERT_FILE(none)TLS certificate file — takes precedence over --tls-cert-dir
--tls-key-fileTLS_KEY_FILE(none)TLS private key file — must be used together with --tls-cert-file
--tls-selfsigned-hostsTLS_SELFSIGNED_HOSTS(none)Extra hostnames/IPs the self-signed certificate must be valid for (comma-separated)
--spaSPA_MODEfalseEnable SPA routing
--show-headersSHOW_HEADERSfalseDisplay request headers on the parking page
--health-pathHEALTH_PATH/_healthPath of the health endpoint
--headers-pathHEADERS_PATH/_headersPath of the request headers endpoint (needs --show-headers)
--cache-max-sizeCACHE_MAX_SIZE50000000Max total cache size in bytes (0 to disable)
--cache-max-fileCACHE_MAX_FILE_SIZE5000000Max individual file size to cache in bytes
--proxyPROXY_ROUTES(none)Proxy route as /prefix=http://target, / proxies everything (repeatable flag, comma-separated env)
--proxy-timeoutPROXY_TIMEOUT30Proxy upstream response timeout in seconds
--proxy-caPROXY_CA_FILE(none)CA certificate file (PEM) for verifying backend TLS — takes precedence over --proxy-insecure
--proxy-insecurePROXY_INSECUREfalseSkip TLS verification for proxy backends — use only on trusted networks
--versionPrint version and exit

--tls-cert-dir defaults to /certs when running as root and to ~/.static-httpserver/certs otherwise, so an unprivileged process always has somewhere to keep its certificate.

The Docker image sets --root-dir /static, --port 8080 and --tls-cert-dir /certs by default.

CLI Usage

# Serve current directory over HTTPS (port 8443)
static-httpserver --root-dir .
curl -k https://localhost:8443/

# Add plain HTTP on port 8080
static-httpserver --root-dir /var/www/html --port 8080
curl http://localhost:8080/

# HTTPS only, ignoring any port set through PORT
static-httpserver --root-dir /var/www/html --only-https

# Listen on one address only
static-httpserver --root-dir . --bind 127.0.0.1

# SPA mode
static-httpserver --root-dir ./dist --spa

Install via deb/rpm

# Debian/Ubuntu
apt install static-httpserver

# RHEL/CentOS
yum install static-httpserver

HTTPS / TLS

HTTPS is always enabled (default port 8443), so the minimal way to serve it is to just start the server:

# CLI: https://localhost:8443
static-httpserver --root-dir ./html

# Docker: publish the HTTPS port and keep the certificate in a volume
docker run -p 8443:8443 \
-v $(pwd)/html:/static:ro \
-v $(pwd)/certs:/certs \
byjg/static-httpserver

HTTP is optional — it only starts when --port or PORT is set. The Docker image sets PORT=8080, so it serves both by default. --only-https (or ONLY_HTTPS=true) turns the HTTP listener off even when a port is set, which is the way to get an HTTPS-only container:

static-httpserver --root-dir ./html --port 8080 # HTTP + HTTPS

docker run -p 8443:8443 -e ONLY_HTTPS=true byjg/static-httpserver # HTTPS only

Both listeners use --bind (default 0.0.0.0, all interfaces). Use it to restrict the server to a single address, e.g. --bind 127.0.0.1 for local-only access.

Self-signed certificate (default)

When --tls-cert-dir has no cert.pem/key.pem, the server generates a self-signed certificate valid for one year and saves it as selfsigned-cert.pem / selfsigned-key.pem inside that same directory/certs as root, ~/.static-httpserver/certs otherwise, created with 0700. On the next start the saved certificate is reused, and it is only regenerated when it is expired (or within 30 days of expiring) or when it does not cover all the requested hostnames.

The Docker image runs as a non-root user (uid 1000) but pins TLS_CERT_DIR=/certs and ships that directory owned by it, so a named volume on /certs inherits the ownership and keeps the same certificate across container restarts. A bind mount takes its ownership from the host instead, so the host directory has to be writable by uid 1000. When the directory cannot be written at all (a read-only mount, or a path the process may not create), the certificate is simply kept in memory: the server still serves HTTPS, it just gets a new certificate on every start.

The certificate covers localhost, 127.0.0.1, ::1, the machine hostname and the address the server listens on — when --bind is a specific IP, that IP; when it is the 0.0.0.0 default, every routable address of the machine, so https://<lan-ip>:8443 verifies too. Note that a machine whose addresses change (a new Docker bridge, a new DHCP lease) no longer matches the saved certificate, which is then regenerated on the next start; --bind <ip> avoids that.

--tls-selfsigned-hosts is optional and only needed for names the server cannot discover, such as a public DNS name:

static-httpserver --root-dir ./html --tls-selfsigned-hosts www.example.org,192.168.1.10

Because the certificate carries proper SubjectAltName entries, clients can be told to trust it instead of skipping verification:

# Quick and dirty: skip verification
curl -k https://localhost:8443/_health

# Or trust the generated certificate
curl --cacert ./certs/selfsigned-cert.pem https://localhost:8443/_health

Your own certificates

Provide a directory containing cert.pem and key.pem:

# CLI
static-httpserver --root-dir ./html --tls-cert-dir /path/to/certs

# Docker
docker run -p 8080:8080 -p 8443:8443 \
-v /path/to/certs:/certs:ro \
byjg/static-httpserver

When both files are present they always take precedence over the self-signed certificate.

If your files are not named cert.pem and key.pem — Let's Encrypt, for instance, writes fullchain.pem and privkey.pem — point at them directly:

static-httpserver --root-dir ./html \
--tls-port 443 \
--tls-cert-file /etc/letsencrypt/live/example.org/fullchain.pem \
--tls-key-file /etc/letsencrypt/live/example.org/privkey.pem

Both flags must be given together. Unlike --tls-cert-dir, an unreadable file here is a fatal error: the server will not silently fall back to a self-signed certificate.

SPA Mode

When enabled, any request that doesn't match an existing file and has no file extension is served the index.html page. This supports client-side routing in frameworks like React, Angular, and Vue.

Requests for missing static assets (e.g., /missing.css) still return 404.

docker run -p 8080:8080 -e SPA_MODE=true byjg/static-httpserver

Reverse Proxy

The server can forward requests matching a path prefix to a backend service. This is useful for:

  • Avoiding CORS issues by serving the frontend and API from the same origin
  • Hiding backend services from direct client access
  • Replacing nginx/caddy as a reverse proxy sidecar in Kubernetes
  • Putting HTTPS in front of a backend that only speaks HTTP
# CLI — multiple routes
static-httpserver --root-dir ./dist --spa \
--proxy /api=http://backend:3000 \
--proxy /auth=http://auth-service:4000

# Docker — comma-separated env
docker run -p 8080:8080 \
-e SPA_MODE=true \
-e PROXY_ROUTES="/api=http://backend:3000,/auth=http://auth:4000" \
byjg/static-httpserver

The proxy strips the prefix before forwarding: a request to /api/users is forwarded as /users to the target.

When several routes match, the most specific prefix wins, whatever order they were given in — /api/admin is chosen over /api, and both over /.

The --proxy-timeout flag (default 30s) controls how long the server waits for a response from the upstream.

Proxied requests carry X-Forwarded-Proto and X-Forwarded-Host (alongside the X-Forwarded-For added by Go), so a backend behind the HTTPS listener can tell that the client spoke HTTPS — Express needs it for req.protocol, secure cookies and absolute redirects. Values set by an upstream proxy are preserved.

HTTPS for a backend that has none

/ is the catch-all prefix: it matches every path and strips nothing, so the whole site can be handed to a backend while static-httpserver terminates TLS with its own certificate. The backend keeps serving plain HTTP and never deals with certificates.

# Node (or any HTTP backend) on :3000, reachable over HTTPS on :443
static-httpserver \
--root-dir /var/www/empty \
--only-https --tls-port 443 \
--proxy /=http://127.0.0.1:3000
# Docker: TLS terminated here, plain HTTP to the app container
docker run -p 8443:8443 \
-e ONLY_HTTPS=true \
-e PROXY_ROUTES="/=http://app:3000" \
-v $(pwd)/certs:/certs \
byjg/static-httpserver

The health endpoint is always answered locally, so it stays usable as a probe even behind a catch-all route. It lives at /_health precisely so it does not collide with a backend's own /health — which is proxied through untouched. Move it with --health-path if the underscore name is taken as well. Everything else goes to the backend — routes are matched before the static file lookup — so point --root-dir at an empty directory when the backend owns the whole site.

Local development: npm run serve-https

This script starts your app and puts static-httpserver in front of it, so a local app gets HTTPS without touching its code. Save it in your project as scripts/serve-https.sh (it is also in this repository as examples/serve-https.sh):

#!/bin/sh
APP_CMD="${APP_CMD:-npm run serve}" # command that starts the app
APP_PORT="${APP_PORT:-3000}" # port the app listens on
TLS_PORT="${TLS_PORT:-8443}" # port to serve HTTPS on
CERT_DIR="${CERT_DIR:-.certs}" # where the self-signed cert is kept
ROOT_DIR="${ROOT_DIR:-.static}" # static files, if any

mkdir -p "$ROOT_DIR"

$APP_CMD &
APP=$!

static-httpserver \
--root-dir "$ROOT_DIR" \
--only-https \
--tls-port "$TLS_PORT" \
--tls-cert-dir "$CERT_DIR" \
--proxy "/=http://127.0.0.1:$APP_PORT" &
SRV=$!

# Ctrl-C (or a stop signal) has to reach both, not just this script.
trap 'kill -TERM $APP $SRV 2>/dev/null' TERM INT

echo "https://localhost:$TLS_PORT -> http://127.0.0.1:$APP_PORT"

wait $SRV
kill -TERM $APP 2>/dev/null
wait $APP 2>/dev/null

Then wire it into package.json:

{
"scripts": {
"serve": "node server.js",
"serve-https": "sh ./scripts/serve-https.sh"
}
}
npm run serve-https
# https://localhost:8443 -> http://127.0.0.1:3000

The ports and the command are environment variables, so the same script works for any stack:

APP_CMD="npm run dev" APP_PORT=5173 npm run serve-https # Vite
VariableDefaultMeaning
APP_CMDnpm run serveCommand that starts the app
APP_PORT3000Port the app listens on
TLS_PORT8443Port to serve HTTPS on
CERT_DIR.certsWhere the self-signed certificate is kept
ROOT_DIR.staticStatic files, if any

Ctrl-C stops both — the script forwards the signal to the app instead of leaving it orphaned. The certificate is kept in CERT_DIR and reused, so the browser exception you add survives restarts; add .certs/ to .gitignore. Requests made before the app finishes booting get a 502 until it is listening.

For containers, run the app and static-httpserver as two containers (compose or a Kubernetes pod) and point --proxy /=http://app:3000 at the app, rather than starting both from one entrypoint.

Proxy backend TLS

When the backend uses HTTPS with a self-signed or private CA certificate, use --proxy-ca to provide the CA cert for verification:

static-httpserver --root-dir ./dist \
--proxy /api=https://internal-service:8443 \
--proxy-ca /path/to/ca.crt

For trusted internal networks (e.g. WireGuard mesh) where managing a CA cert is impractical, --proxy-insecure skips verification entirely:

static-httpserver --root-dir ./dist \
--proxy /api=https://internal-service:8443 \
--proxy-insecure

Note: If both --proxy-ca and --proxy-insecure are set, --proxy-ca takes precedence and a warning is logged. Never use --proxy-insecure for backends reachable from untrusted networks.

TLS termination with hardened OIDC exposure

A common pattern is to use static-httpserver as a TLS-terminating reverse proxy that exposes only specific OIDC/OAuth endpoints publicly, while keeping the rest of the API internal:

static-httpserver \
--root-dir /var/www/empty \
--tls-port 443 \
--tls-cert-file /etc/letsencrypt/live/nimbus.example.com/fullchain.pem \
--tls-key-file /etc/letsencrypt/live/nimbus.example.com/privkey.pem \
--proxy /.well-known/openid-configuration=https://10.106.103.1:8443/.well-known/openid-configuration \
--proxy /keys=https://10.106.103.1:8443/keys \
--proxy /authorize=https://10.106.103.1:8443/authorize \
--proxy /oauth/token=https://10.106.103.1:8443/oauth/token \
--proxy /login=https://10.106.103.1:8443/login \
--proxy /callback=https://10.106.103.1:8443/callback \
--proxy /userinfo=https://10.106.103.1:8443/userinfo \
--proxy-ca /var/lib/nimbus/ca.crt

In this setup:

  • The browser connects over HTTPS with a trusted public cert (Let's Encrypt)
  • Only OIDC endpoints are forwarded — the rest of the API returns 404
  • The backend connection is verified using the internal CA cert
  • The internal API remains unreachable from the public internet

Health Check

The server exposes a /_health endpoint that returns {"status":"ok"} with HTTP 200. This is used by the Helm chart for Kubernetes liveness and readiness probes.

The underscore keeps it out of the way of an application's own /health, which matters when the whole site is proxied to a backend. --health-path (env HEALTH_PATH) moves it; the Helm chart exposes it as parameters.healthPath and points the probes at whatever it is set to.

Upgrading: the endpoint used to be /health. Kubernetes probes defined outside this chart, uptime monitors and load balancer checks have to be pointed at /_health, or the old path restored with --health-path /health.

Using with Helm 3

3.2. Using HELM 3

Minimal configuration

helm repo add byjg https://opensource.byjg.com/helm
helm repo update
helm upgrade --install mysite byjg/static-httpserver \
--namespace default \
--set "ingress.hosts={www.example.org,example.org}" \
--set parameters.title=Welcome

Parameters:

ingress:
hosts: [] # Required
parameters:
htmlTitle: ""
title: "soon"
message: ""
backgroundImage: ""
facebook: ""
twitter: ""
youtube: ""
spaMode: ""
showHeaders: ""
healthPath: "" # defaults to /_health
rootDir: ""
port: ""
tlsPort: ""
tlsCertDir: ""
cacheMaxSize: ""
cacheMaxFileSize: ""
This HELM package is setup to work with [EasyHAProxy](https://github.com/byjg/docker-easy-haproxy)

Enabling as Addon on MicroK8s

The Parking addon deploys a static webserver to ‘park’ a domain. This involves all necessary ingress, service and Pods. This addon adds the proper labels which can be discovered by EasyHAProxy.

To enable this addon:

microk8s enable parking <domainlist>

… where domainlist is the comma separated list of domains to be parked.

To disable the addon:

microk8s disable parking

Follow this discussion: https://discuss.kubernetes.io/t/addon-parking/23186

Use your own static pages

Mount your own HTML directory to replace the default parking page:

docker run -p 8080:8080 -v /path/to/local/html:/static byjg/static-httpserver

Create your own image

FROM byjg/static-httpserver

COPY /path/to/html /static

Using with React / Vue / Angular (SPA)

Use a multi-stage Dockerfile to build your frontend app and serve it with SPA routing:

FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM byjg/static-httpserver
ENV SPA_MODE=true
COPY --from=builder /app/build /static

Note: adjust the build output folder depending on your framework:

  • React (CRA): build
  • Vite: dist
  • Next.js (static export): out
  • Angular: dist/<project-name>/browser

Then build and run:

docker build -t myapp .
docker run -p 8080:8080 myapp

Open source ByJG