Static Sites at Scale: Docker, HAProxy, and GitHub Actions


I run somewhere between five and ten domains at any given time. Personal sites, project sites, a shared site with my partner, side projects that may or may not ever go anywhere. For a long time, deploying an update to any of them meant SSHing into my server, pulling files manually, restarting Apache, and hoping I didn't break anything else in the process.

I fixed that. Now it's git push and done. This is how the system works.


The Architecture

Every site is a Docker container. A single HAProxy container sits in front of all of them on port 80, routing incoming traffic by hostname. Each site lives in its own GitHub repository and deploys itself via GitHub Actions on every push to main. A private Docker registry on the server acts as the image store between the build runner and the server itself.

Example domain map:

DomainContainer portRepo
infosecg.xyzX001httpd-infosecg
g-net.caX002httpd-gnet
pwnedbygeoff.comX003httpd-pwnedbygeoff

Each site container binds its port directly to the host. HAProxy routes to those host ports. Simple, explicit, no magic.


The Site Repo Pattern

Every static site repo follows the same structure:

httpd-sitename/
├── Dockerfile
├── www/             ← your actual site
│   ├── index.html
│   └── assets/
└── .github/
    └── workflows/
        └── deploy.yml

The Dockerfile is nearly identical across all of them:

FROM almalinux:latest

RUN yum -y install httpd && yum clean all

RUN mkdir -p /var/run/httpd /run/httpd /etc/httpd/logs && \
    touch /etc/httpd/logs/error_log /etc/httpd/logs/access_log && \
    chown -R apache:apache /var/run/httpd /run/httpd /etc/httpd/logs && \
    chmod -R 777 /var/run/httpd /run/httpd /etc/httpd/logs

WORKDIR /var/www/html
USER apache
ADD www /var/www/html

ENTRYPOINT ["/usr/sbin/httpd", "-D", "FOREGROUND"]
EXPOSE 80

The site content is baked into the image at build time. No volumes, no mutable state, no runtime config. The image is the site. This is the correct Docker pattern for static content — if you need to roll back, you pull an older image.

One note on base image: the older repos in this setup still use centos:latest, which is now fully gone from Docker Hub. AlmaLinux is the drop-in replacement — same package names, same paths, same apache user. If you're running CentOS-based containers, migrate now rather than when your pipeline breaks.


The Pipeline

Three sequential jobs. Every push to main triggers all three.

Job 1: Security scan

Semgrep runs against the repo with --config=auto --error. If it finds anything blocking, the pipeline stops here and nothing deploys. For a static site repo this catches things like hardcoded secrets, supply-chain issues in GitHub Actions config, and the occasional HTML/JS finding.

- name: Run Semgrep Security Scan
  run: semgrep scan --config=auto --error

This gate cost me a pipeline failure on day one: my deploy.yml was using actions/checkout@v3 — a mutable tag that can be silently repointed. Semgrep flagged it as a supply-chain risk. The fix is pinning to a full commit SHA:

uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3

More annoying to read, correct to do. Get the SHA with git ls-remote https://github.com/actions/checkout refs/tags/v3.

Job 2: Build and push

The GitHub Actions runner builds the Docker image and pushes it to the private registry on the server. The runner is configured to trust the private registry, then builds and pushes the image:

- name: Build and Push Image to Private Registry
  run: |
    docker build -t ${{ secrets.SERVER_IP }}:${{ secrets.REGISTRY_PORT }}/${{ secrets.DOCKER_NAME }} .
    docker push ${{ secrets.SERVER_IP }}:${{ secrets.REGISTRY_PORT }}/${{ secrets.DOCKER_NAME }}

The secrets (SERVER_IP, SERVER_USER, SSH_PRIVATE_KEY, DOCKER_NAME) are set per-repo in GitHub's repository settings. DOCKER_NAME is the image/container name — infosecg, gnet, etc.

Job 3: Deploy

SSH into the server, pull the new image from the local registry, swap the container:

- name: Deploy via SSH
  uses: appleboy/ssh-action@d91a1af6f57cd4478ceee14d7705601dafabaa19 # v0.1.7
  with:
    host: ${{ secrets.SERVER_IP }}
    username: ${{ secrets.SERVER_USER }}
    key: ${{ secrets.SSH_PRIVATE_KEY }}
    script: |
      docker pull ${{ secrets.SERVER_IP }}:${{ secrets.REGISTRY_PORT }}/${{ secrets.DOCKER_NAME }}
      docker stop ${{ secrets.DOCKER_NAME }} || true
      docker rm   ${{ secrets.DOCKER_NAME }} || true
      docker run -d --name ${{ secrets.DOCKER_NAME }} \
        --net dockernet -p X001:80 \
        -t ${{ secrets.SERVER_IP }}:${{ secrets.REGISTRY_PORT }}/${{ secrets.DOCKER_NAME }}

The || true on stop and rm means first deploys don't fail on a missing container. The port number is the one thing that differs per-site — everything else is driven by secrets.

Total pipeline time is roughly 2–4 minutes from push to live.


HAProxy: The Front Door

HAProxy runs as its own container, built from a minimal Dockerfile:

FROM haproxy:latest
COPY haproxy.cfg /usr/local/etc/haproxy/haproxy.cfg

The config bakes in all the routing logic. The relevant section:

frontend http_front
    bind *:80
    mode http

    acl g-net        hdr(host) -i g-net.ca www.g-net.ca
    acl infosecg     hdr(host) -i infosecg.xyz www.infosecg.xyz
    acl pwnedbygeoff hdr(host) -i pwnedbygeoff.com www.pwnedbygeoff.com

    use_backend g-net_webserver        if g-net
    use_backend infosecg_webserver     if infosecg
    use_backend pwnedbygeoff_webserver if pwnedbygeoff
    default_backend default_webserver

backend infosecg_webserver
    server infosecg SERVER_IP:X001 check

HAProxy reaches the site containers via the server's own IP on their host-bound ports. Simple, explicit, and easy to reason about. The stats dashboard is exposed on a separate port — useful for seeing which backends are healthy at a glance.

The HAProxy repo has its own pipeline, identical in structure to the site repos (minus the Semgrep gate). Push a config change, the image rebuilds, HAProxy redeploys. The key difference in the docker run step: HAProxy binds directly to port 80 on the host, not a high-numbered port:

docker run -d --name haproxy -p 80:80 -t SERVER_IP:REGISTRY_PORT/haproxy

The Private Registry

A private Docker registry runs on the server. GitHub Actions pushes the freshly-built image to it; the deploy step pulls from it locally on the server. This means:

  • No Docker Hub rate limits
  • No credentials for private images
  • Images stay on your own infrastructure
  • Pulls during deploy are localhost-fast

The registry port is stored as a secret alongside the other pipeline secrets, keeping the address out of the repo entirely.


Adding a New Site

Four steps:

  1. Create a new repo following the template above. Pick the next unused high-numbered port and update the docker run command in deploy.yml.
  2. Set the four secrets on the new repo: SERVER_IP, SERVER_USER, SSH_PRIVATE_KEY, DOCKER_NAME.
  3. Update haproxy.cfg in the httpd-haproxy repo: add an ACL, a use_backend line, and a backend block. Push — HAProxy redeploys.
  4. Point DNS for the new domain at the server.

Steps 1 and 2 are fully isolated — they only touch the new repo. Steps 3 and 4 are the coordination overhead: the HAProxy config is a static file, so every new site requires a central change and a brief HAProxy restart (a few seconds of downtime across all sites).


What It Doesn't Do (Yet)

HTTPS. HAProxy listens on port 80 only — TLS termination is handled by Cloudflare. Client-to-Cloudflare traffic is HTTPS, enforced via Cloudflare's force HTTPS setting. Cloudflare-to-origin is HTTP, which is an accepted tradeoff for simple static sites behind a proxy. If end-to-end encryption mattered here, HAProxy would need a cert and a port 443 listener.

Zero-downtime deploys. The docker stop / docker rm / docker run sequence has a window of a few seconds where the container isn't running. For low-traffic personal sites this is acceptable; for anything with real users it isn't.

Dynamic routing. HAProxy's config is baked into its image. There's no service discovery — adding a site means editing a config file and redeploying. Something like Traefik with Docker labels would remove this friction entirely, at the cost of more moving parts.


What It Does Do Well

The whole system is about 150 lines of config total across all repos. It's entirely understandable in an afternoon. Deployments are automatic, audited, gated on a security scan, and leave a full trail in GitHub's Actions log. Rolling back is pulling an older image. Adding a site is a template copy and four secrets. The private registry means none of this touches Docker Hub.

For a homelab running half a dozen personal domains, it's exactly the right amount of infrastructure.