My Homepage

How I Built and Automated My Personal Homepage with Hugo, Forgejo, and Containers

I run a small personal homepage and blog, which serves two main purposes: as a personal profile for self-marketing and as a place to share my projects, experiences, and learning.

Since I want my homepage to be lightweight, fast, and secure, I decided to use a static site generator. After some research, I settled on careercanvas, a project by Felipe Cordero built on top of the Hugo static site generator. Careercanvas is especially well-suited for personal resumes and developer portfolios, making it a great fit for my use case.

Setting Up the Base Project

Careercanvas is still a work in progress and doesn’t integrate perfectly with Hugo template projects yet. For now, the recommended approach is to fork Felipe’s homepage repository: felipecordero.github.io.

I forked it privately into my self-hosted Forgejo instance, and since I made a few modifications to the theme itself, I also forked careercanvas into my own repository.

Automating the Build with Forgejo Actions

To make publishing updates to my homepage easier, I wrote a Forgejo action that runs on my private Forgejo runner. Whenever I create a new tag in the repository, the workflow kicks in and takes care of everything:

...
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          submodules: recursive

      - name: Install Hugo CLI
        run: |
          wget -O ${{ runner.temp }}/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb \
          && dpkg -i ${{ runner.temp }}/hugo.deb

      - name: Install Node.js dependencies
        run: "[[ -f package-lock.json || -f npm-shrinkwrap.json ]] && npm ci || true"

      - name: Install podman
        run: |
          apt update
          apt install -y podman

      - name: Get date
        id: date_step
        run: echo "TAG=$(date '+%Y-%m-%d_%H-%M-%S')" >> $GITHUB_OUTPUT

      - name: Build homepage
        run: |
          npm install
          rm -rf public
          npm run build:css
          npm run build
          tar -czf "tomirgang-${{ steps.date_step.outputs.TAG }}.tar.gz" public/*

      - name: Upload homepage artifact
        uses: actions/upload-artifact@v3
        with:
          name: tomirgang.tar.gz
          path: tomirgang-${{ steps.date_step.outputs.TAG }}.tar.gz

      - name: Build container
        run: |
          podman build -t git.tomirgang.de/tom/tomirgang:latest .

      - name: Build tag the container
        run: |
          podman tag git.tomirgang.de/tom/tomirgang:latest git.tomirgang.de/tom/tomirgang:${{ steps.date_step.outputs.TAG }}
          podman image ls


      - name: Upload latest container
        run: |
          podman push --creds tom:${{ secrets.HOMEPAGE_CONTAINER_TOKEN }} git.tomirgang.de/tom/tomirgang:latest docker://git.tomirgang.de/tom/tomirgang:latest

      - name: Upload date-tagged container
        run: |
          podman push --creds tom:${{ secrets.HOMEPAGE_CONTAINER_TOKEN }} "git.tomirgang.de/tom/tomirgang:${{ steps.date_step.outputs.TAG }}" "docker://git.tomirgang.de/tom/tomirgang:${{ steps.date_step.outputs.TAG }}"

At a high level, the action does the following:

  1. Prepares a fresh container environment with all required dependencies (Hugo, Node.js, Podman).
  2. Generates a timestamp tag for consistent versioning.
  3. Builds the site with Hugo, including CSS assets, and creates a .tar.gz archive of the generated files.
  4. Uploads the tarball as a build artifact for inspection.
  5. Builds an Nginx-based container that already contains the generated static site.
  6. Publishes the container to my Forgejo registry, both as latest and as a timestamped version.

This approach makes deployments simple and repeatable: the final result is a ready-to-run container image with Nginx serving the static site.

Deployment with Docker Compose and Watchtower

On the server, I run the container using docker-compose:

services:
  homepage:
    image: git.tomirgang.de/tom/tomirgang:latest
    container_name: homepage
    ports:
      - 2210:80
    restart: unless-stopped

  watchtower:
    image: containrrr/watchtower
    container_name: watchtower
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    restart: unless-stopped
    command: --interval 3600

Here’s how it works in practice:

  • The homepage container pulls the latest image built by my Forgejo action.
  • Watchtower checks once an hour for updates. If a new container image is available, it automatically pulls it and restarts the service.

This means publishing a change is as simple as:

  1. Editing content or creating a new blog post.
  2. Committing and pushing to my repository.
  3. Creating a release (which triggers the Forgejo action).
  4. Waiting up to an hour for Watchtower to update the container.

Hosting Setup

What makes this setup even more fun: everything (homepage, Forgejo instance, and Forgejo runner) is hosted on the same inexpensive server — a Hetzner CX22 for just about €5/month.

It’s a neat, cost-effective, and self-contained system that gives me full control over my site, with automated builds and deployments requiring almost no manual intervention.