Back to Projects
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.

GitHub Actions Docker GHCR FastAPI nginx

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/contact to 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.