Personal Infrastructure • Live
CI/CD Pipeline & Self-Hosted Contact API
This site's own build-test-deploy pipeline: push to main, GitHub Actions builds and pushes a Docker image, and deploys a self-hosted contact form handler to this VPS — replacing a dead third-party form service.
The Problem
This site's contact form was wired to a third-party form service with a placeholder ID that was never finished — the form silently failed for anyone who tried to use it. Beyond fixing that one bug, there was no deployment pipeline at all: every change meant manually editing files on the live server.
The Solution
Built a proper pipeline end to end:
Build & deploy
- Self-hosted contact handler: small FastAPI service with a honeypot field and per-IP rate limiting, relaying mail via Gmail SMTP — replacing the third-party form entirely.
- Containerised: packaged with Docker, built and pushed to GitHub Container Registry on every push to main.
- Automated deploy: GitHub Actions syncs the static site and the container's compose file to the VPS over SSH, then pulls and restarts the container.
- nginx proxies
/api/contactto the container, which only listens on localhost — never directly exposed.
Debugging the deploy, in order
None of this worked first try — each fix uncovered the next issue:
- Wrong SSH port: the pipeline assumed the default port 22; the VPS runs SSH on a non-default port. Fixed by specifying it explicitly in the workflow.
- File permissions: the deploy user didn't own the webroot (it was owned by the nginx user from prior manual management). Reassigned ownership rather than loosening permissions broadly.
- CSF vs Docker: ConfigServer Security & Firewall rebuilds iptables on reload and doesn't know about Docker's chains by default, so containers couldn't reach the network. Fixed via CSF's own Docker integration setting.
- Docker's default address pool vs CSF's trusted range: `docker compose` creates its own per-project network, which fell outside the specific subnet CSF was configured to trust. Widened CSF's trusted range to cover Docker's whole default pool.
- GHCR pull authentication: a fresh image wouldn't pull because packages from a private repo default to private visibility. Made the package public, since the image itself contains no secrets — all config is injected at runtime via a `.env` file that never enters the repo.
- A network-mode/library conflict, last: compose's project network didn't play well with a specific Docker networking feature, discovered only once everything else was working. Switched the container to host networking instead — a deliberate, explicit trade-off (loses Docker's network isolation) rather than continuing to patch around the conflict, appropriate for a single low-risk internal service that only ever listens on loopback.
Safety rails
- Secrets (SMTP credentials) live only in a `.env` file on the server, never in the image or the repo.
- The rsync deploy step explicitly excludes a separate personal project living on the same server, so an unrelated app can never be touched by this pipeline.
- Every fix was verified by actually running it — hitting the health endpoint, submitting the real form, checking logs — rather than assuming a green CI run meant the feature worked.