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:
| Domain | Container port | Repo |
|---|---|---|
| infosecg.xyz | X001 | httpd-infosecg |
| g-net.ca | X002 | httpd-gnet |
| pwnedbygeoff.com | X003 | httpd-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:
- Create a new repo following the template above. Pick the next unused high-numbered port and update the
docker runcommand indeploy.yml. - Set the four secrets on the new repo:
SERVER_IP,SERVER_USER,SSH_PRIVATE_KEY,DOCKER_NAME. - Update
haproxy.cfgin thehttpd-haproxyrepo: add an ACL, ause_backendline, and a backend block. Push — HAProxy redeploys. - 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.