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.