Containerized Portfolio with CI/CD

This site, packaged as an nginx container image. GitHub Actions checks it on every push, smoke-tests the image and publishes versioned tags to GitHub Container Registry.

CompletedDevOps & Cloud

Problem

A static site is easy to deploy by hand and just as easy to deploy inconsistently: there is no versioned artifact, no record of which build is live, and no known-good version to return to.

Why I built it. I wanted a small project where I own the whole path from commit to a versioned container image, using the same pieces a larger service would use.

How it works

From commit to container image. Select a step to read what happens there.

Push to main

A commit on main starts the workflow. It can also be started by hand with workflow_dispatch.

Approach

The site is built into plain static files, copied into an nginx:alpine image, and published to GHCR by GitHub Actions on every push to main.

Run the published image
docker run -p 8080:80 ghcr.io/garvg4278/garv-portfolio:latest

Technology and why

Multi-stage Docker build
Node and the build tooling stay in the first stage. The final image contains only nginx and the built files.
nginx:alpine
A small, standard static server. Caching, security headers and redirects live in one config file, nginx.conf.
GitHub Actions
The pipeline lives in the same repository as the code, so changes to either are reviewed together.
GHCR
The image sits next to the source and is addressable by tag, including the commit SHA.

What I implemented

  • Wrote the original Dockerfile and workflow: a single-stage nginx:alpine image, a GHCR login using a personal access token stored as a repository secret, and a push of the latest and short-SHA tags.
  • Rebuilt the site in Astro and Three.js in October 2026, with content in data files, shared components and a small logic layer with unit tests.
  • Moved the Dockerfile to a multi-stage build and added an nginx configuration: long-lived caching for hashed assets, no-cache for HTML, gzip, security headers, a custom 404 page and redirects from the old URLs.
  • Extended the workflow with lint, type-check, tests, a link check and an image smoke test before anything is pushed.

Decisions and trade-offs

  • Kept nginx:alpine on port 80 so the docker run command already documented in the README still works.
  • Kept the GHCR login on the existing personal access token (GHCR_PAT) rather than changing registry permissions during the rebuild.

Testing and verification

  • CI runs ESLint, astro check, unit tests, a production build and a check of every internal link, asset and anchor in the generated pages.
  • Each image carries the commit SHA as a tag, so a running container can be traced back to a commit.

Results

  • Every push to main produces two image tags in GHCR: latest and the short commit SHA.

Limitations and next steps

  • The workflow publishes the image. It does not roll the new image out to the host; that step happens outside this repository.
  • There are no automated browser or accessibility tests in CI yet.
  • The registry login uses a long-lived personal access token instead of the workflow token.

Are you sure?