Portfolio Design #06: GitOps & Going Live

Introduction

In my previous posts, I covered the API architecture, data layer, and monitoring setup for my portfolio and blog. This final post covers the operational side: how I deploy updates using a simple GitOps workflow.

Currently, I use a semi-automated approach: automated builds on release, but manual deployment. The build process is consistent and reproducible, and I plan to automate deployment soon.

CI Pipeline: Automated Builds

GitHub Actions on Release

When I create a GitHub release, a GitHub Actions workflow automatically builds and publishes a Docker image:

on:
  release:
    types: [published]

jobs:
  build-amd64:
    runs-on: ubuntu-latest
    steps:
      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: ${{ secrets.DOCKERHUB_USERNAME }}/portfolio:${{ github.ref_name }}

(Simplified - the actual workflow also handles version extraction and manifest creation)

What happens: 1. Extract version from release tag (e.g., v1.0.0 -> 1.0.0) 2. Build Docker image for linux/amd64 3. Push to Docker Hub with version tag and latest tag

This ensures every release has a tagged, reproducible build without manual Docker commands.

Docker Build Process

The Dockerfile uses a multi-stage build to keep the final image small:

# Stage 1: Build UI assets
FROM node:20-alpine AS ui-builder
RUN npm ci && npm run build

# Stage 2: Generate templ templates
FROM golang:1.25-alpine AS templ-generator
RUN go install github.com/a-h/templ/cmd/[email protected]
RUN templ generate

# Stage 3: Build Go binary
FROM golang:1.25-alpine AS go-builder
COPY --from=ui-builder /ui/dist/ ./pkg/static/
COPY --from=templ-generator /app/ ./
RUN go build -o /portfolio ./cmd/app

# Stage 4: Final image
FROM alpine:latest
COPY --from=go-builder /portfolio /portfolio
ENTRYPOINT ["/portfolio"]

(Simplified - shows the key stages; actual Dockerfile includes WORKDIR, dependency copying, etc.)

Stages: 1. UI Builder: Compiles SCSS and React islands 2. Templ Generator: Generates Go code from templ templates 3. Go Builder: Compiles Go binary with embedded static assets 4. Final Image: Minimal Alpine image with just the binary

The final image is ~73.4 MB, containing the compiled binary, static assets, and minimal Alpine runtime dependencies.

Deployment: Manual (for now)

Currently, deployments are manual. I SSH into my VPS and run:

# Pull latest images
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull

# Update services
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

Future automation: This can be fully automated by adding a deployment step to the GitHub Actions workflow, but I haven’t set up the SSH keys or secrets needed yet.

For now, manual deployment works fine for my release frequency, but automating this is on my roadmap.

The docker-compose.prod.yml override file sets production-specific settings (image tags, health checks, volume mounts) while keeping base configuration in docker-compose.yml.

Version Management

The production compose file uses environment variables:

services:
  api:
    image: ${DOCKERHUB_USERNAME}/portfolio:${VERSION:-latest}

I can deploy a specific version by setting VERSION=1.0.0, or use latest for the most recent build. This makes rollbacks trivial:

VERSION=1.0.0 docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

Conclusion

The deployment workflow is simple: 1. Create GitHub release - Triggers automated Docker build and publish 2. SSH to VPS - Run docker compose commands to update (manual for now)

This gives me: - Reproducible builds via GitHub Actions - Versioned releases with Docker tags - Easy rollbacks by specifying version tags

Next steps: I plan to automate the deployment step by adding an SSH action to the GitHub workflow, making the entire process fully automated from release to production.


This concludes my 6-part series on building out my portfolio and blog. I’ve covered the core architecture decisions that make this system simple, maintainable, and cost-effective.