CI/CD for a Personal Project: GitHub Actions to GHCR to Kubernetes

Push to main, run 414 tests, build a Docker image, push to GHCR. But then what? The gap between "image built" and "running in the cluster" is where most personal project CI/CD stories stop.

CI/CD for a Personal Project: GitHub Actions to GHCR to Kubernetes

Life Hub is a FastAPI app that I push to production multiple times a week. The CI/CD pipeline is simple by design: push to main, run tests, build a Docker image, push to GHCR. But the interesting part isn't the build. It's what happens between "image pushed" and "running in the cluster", and the gap I haven't fully closed yet.

The Pipeline

Push to main → GitHub Actions → Test (414 tests) → Build → Push to GHCR → ??? → Running in K8s

That question mark is the honest part.

Stage 1: Test

The first job runs the full test suite against an in-memory SQLite database:

test:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-python@v5
      with:
        python-version: "3.12"
    - run: pip install -r requirements.txt pytest anyio httpx
    - run: pytest tests/ -v
      env:
        API_KEY: test-key
        DATABASE_PATH: ":memory:"

414 tests across 36 files. They cover every API endpoint, every collector, every ingest path, the backup system, the gamification engine, and the MCP server. Tests run in about 30 seconds because SQLite in-memory is fast and there's no external service dependency: everything is mocked or uses the ASGI test transport.

The DATABASE_PATH: ":memory:" trick: every test gets a fresh database. No cleanup needed. No state leaks between test files. The conftest.py sets this env var at module level before any app import, which means the app's config singleton picks it up before creating the engine.

Stage 2: Build

The build job depends on tests passing. It uses a multi-stage Dockerfile:

# Build stage: compile CSS, install Python deps
FROM python:3.12-slim AS build
WORKDIR /build
RUN apt-get update && apt-get install -y build-essential
COPY requirements.txt .
RUN pip install --prefix=/install -r requirements.txt

# Download and run Tailwind CSS standalone CLI
RUN curl -sLO https://github.com/.../tailwindcss-linux-x64
COPY . .
RUN ./tailwindcss-linux-x64 -i app/static/input.css -o app/static/styles.css --minify

# Runtime stage: slim image with only what we need
FROM python:3.12-slim AS runtime
RUN useradd -m -u 1000 appuser
COPY --from=build /install /usr/local
COPY --from=build /build/app /app/app
ARG BUILD_SHA=dev
RUN echo "$BUILD_SHA" > /app/app/BUILD_SHA

USER appuser
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Key decisions:

  • Tailwind compiled at build time. The standalone CLI downloads in the build stage, compiles CSS, and the output is copied to the runtime stage. No Node.js in the final image.
  • Non-root user. UID 1000, matching the Kubernetes securityContext.
  • BUILD_SHA baked in. The short commit SHA is written to a file that the app reads at startup. The /health endpoint returns it:
{"status": "ok", "version": "1.0", "build": "7a3f8c9"}

One curl to the health endpoint tells you exactly which commit is running.

Stage 3: Push to GHCR

The workflow pushes two tags per build:

tags: |
  ghcr.io/snaviaux/life-hub:7a3f8c9   # Immutable SHA tag
  ghcr.io/snaviaux/life-hub:latest     # Mutable convenience tag

Two tags for two purposes. The short SHA is immutable: it identifies exactly which commit produced this image. :latest always points to the most recent build and is what the Kubernetes Deployment references.

The GitHub Actions cache (type=gha) stores Docker layer caches between runs. A typical build that only changes Python code takes 40-60 seconds because the dependency installation layer is cached.

Stage 4: The Gap

Here's where the story gets honest. The image is in GHCR. How does it get to the cluster?

The Kubernetes Deployment references ghcr.io/snaviaux/life-hub:latest. Flux CD manages the manifests via GitOps. But there's no automatic trigger that says "a new image was pushed, restart the pods."

Currently, deployment happens one of three ways:

  1. Manual rollout restart: kubectl rollout restart deployment/life-hub -n life-hub. This is what I do most of the time.
  2. Pod eviction: If a pod gets evicted (node drain, failed health check, OOM kill), the replacement pulls the latest image. Incidental deployment.
  3. Manifest change: If I also change something in the home-ops repo, Flux applies the change and the pod restarts with the new image.

What's missing is Flux Image Automation: the set of CRDs (ImageRepository, ImagePolicy, ImageUpdateAutomation) that would scan GHCR for new tags, update the manifest in Git with the SHA tag, and trigger reconciliation automatically.

Why I Haven't Closed the Gap

Because the manual approach works fine for a personal project.

I push code a few times a week. The build takes about 90 seconds. Running kubectl rollout restart takes 2 seconds. Total deploy time is under 2 minutes, and I'm usually watching the build anyway because I want to make sure the tests pass.

Flux Image Automation would add three CRDs, a scanning interval, and a commit-back loop where Flux edits its own Git repo. For a team or multi-service platform, that automation is essential. For one person deploying one service, it's overhead I don't need yet.

The CronJob Angle

There's a second consumer of the image: the collector CronJob. It runs every 15 minutes using the same :latest image:

containers:
- name: collector
  image: ghcr.io/snaviaux/life-hub:latest
  command: ["python", "collector_cli.py"]

CronJobs create a new Pod every run. Each new Pod pulls the image per the pull policy. This means the CronJob naturally picks up new images within 15 minutes of a push, no restart needed. It's accidental continuous deployment through scheduling frequency.

The Deployment pods, by contrast, keep running until something forces a restart. The mismatch is theoretically problematic (the collector could be running newer code than the API), but in practice it's never caused an issue because API changes are backward compatible.

What I'd Change for a Team

  1. Pin image tags to SHA. The Deployment would reference :7a3f8c9, not :latest. Flux Image Automation would scan for new tags, update the manifest, and commit.
  2. Add a staging environment. A separate namespace deployed first with smoke tests before promoting.
  3. Add deployment notifications. Push to ntfy when a new image is deployed.

The Numbers

MetricValue
Tests414 across 36 files
Test runtime~30 seconds
Build runtime (cached)~60 seconds
Build runtime (cold)~3 minutes
Image size~250 MB
Push to deploy (manual)~2 minutes
Push to deploy (CronJob)~15 minutes

Lessons

Test in CI, not in production. 414 tests running before every build means I've caught breaking changes before they reach the cluster. The in-memory SQLite trick makes tests fast enough that I never skip them.

Bake the commit SHA into the image. One small echo in the Dockerfile saves hours of "which version is running?" debugging. Make your health endpoint useful.

Close the gap when the pain justifies it. The manual rollout restart works for me, today. When it stops working, I'll add Flux Image Automation. Engineering for a future problem I don't have is how homelabs become unmaintainable.

:latest is fine for a solo project. I know this is heresy. Every best-practices guide says to pin tags. They're right for teams. For a single-developer homelab, :latest is the path of least friction, and the SHA tags are there when I need to roll back.