↓Skip to main content

Private Source, Public Website: Deploying a Hugo Blog with GitHub Actions

·8 mins

I wanted to publish this blog without maintaining a server, but I also did not want to make its source repository public. The repository contains more than the finished website. It includes the history of the blog, unfinished articles, configuration, and potentially personal notes that I may add later.

Hugo already creates a useful boundary for this problem. The source stays in one place, while the public/ directory contains the static files visitors need. My deployment keeps the source in a private repository and publishes only that generated directory through a second, public repository.

The result is a public website with private source material and no additional server to operate. This is not a way to make a website private. Anything Hugo includes in the generated output remains public.

This post explains how to reproduce that setup with GitHub Actions, an SSH deploy key, and GitHub Pages.

The two-repository setup #

The repositories have different responsibilities:

RepositoryVisibilityContents
blogPrivateHugo content, configuration, scripts, and the GitHub Actions workflow
gh-pages-blogPublicGenerated files from Hugo’s public/ directory

The deployment follows one path from source to website:

flowchart LR source[Private blog repository] -->|push to main| actions[GitHub Actions] actions -->|build and check| output[Hugo public directory] actions -->|SSH push| repository[Public deployment repository] repository -->|publish main from root| pages[GitHub Pages] pages --> domain[constantinsblog.eu]

I treat the public repository as an artifact repository. I do not write posts or edit generated HTML there. If the repository were lost, I could recreate its contents by building the private source repository again.

To follow this approach, create:

  1. A private repository containing the Hugo site
  2. A public repository that will contain the generated site
  3. A main branch in the public repository

The public repository can start with an initial commit. The first deployment will replace its contents with the generated website.

Give the workflow access to the public repository #

The workflow runs in the private repository but needs permission to push to the public one. I use a dedicated SSH deploy key rather than a personal key or a token with access to several repositories.

Generate a new key pair locally:

ssh-keygen -t ed25519 -C "blog-pages-deployment" -f ./blog-pages-deploy-key

This creates a private key named blog-pages-deploy-key and a public key named blog-pages-deploy-key.pub.

In the public deployment repository, open Settings → Deploy keys, add the public key, and enable Allow write access. The key is then restricted to that repository.

In the private source repository, open Settings → Secrets and variables → Actions and add the private key as a repository secret named GH_PAGES_DEPLOY_KEY.

The private key should never be committed to either repository. After adding both parts to GitHub, remove the local key files if they are no longer needed. If the private key is ever exposed, remove the deploy key from the public repository and replace it.

Build Hugo in GitHub Actions #

The workflow starts after a push to main. I also allow manual runs, which are useful when testing deployment changes:

name: Build and publish Hugo site

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

The explicit permission is intentionally narrow. The workflow only needs to read the private source repository. The SSH deploy key supplies the separate write permission for the public repository.

The first steps check out the source and install a pinned Hugo version:

jobs:
  publish:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout source
        uses: actions/checkout@v5
        with:
          submodules: recursive
          fetch-depth: 0

      - name: Install Hugo
        uses: peaceiris/actions-hugo@v3
        with:
          hugo-version: '0.165.0'
          extended: true

Pinning the version avoids depending on whatever happens to be installed on the runner. I enable Extended Hugo because the theme requires it.

The build itself is strict:

      - name: Build site
        run: >-
          hugo --gc --cleanDestinationDir --minify
          --printI18nWarnings --printPathWarnings --panicOnWarning

--cleanDestinationDir removes files from previous builds, while --panicOnWarning prevents Hugo from publishing after a warning. The result is written to public/, which remains ignored by the private source repository.

Check the generated site #

A successful Hugo command does not guarantee that every generated internal link points to an existing file. I therefore added a small local checker as another publication gate:

      - name: Check generated internal links
        run: python3 scripts/check-internal-links.py public

The script parses the generated HTML and verifies same-site href and src targets against the contents of public/. It does not request external websites or attempt to validate every possible browser resource. Its purpose is narrower: catch missing internal pages and assets before deployment.

The checker uses only Python’s standard library, so it does not add another package installation step. It is useful here because it operates on Hugo’s final output, after content paths, language prefixes, and output formats have been resolved.

A simpler setup can omit this check. The deployment architecture does not depend on it, but I prefer to stop the workflow rather than knowingly replace a working site with generated output that fails this basic validation.

Push the generated files to the public repository #

After the build and validation succeed, the workflow loads the private deploy key:

      - name: Configure Pages deploy key
        uses: webfactory/ssh-agent@v0.10.0
        with:
          ssh-private-key: ${{ secrets.GH_PAGES_DEPLOY_KEY }}

It can now clone the public repository over SSH:

      - name: Checkout Pages repository
        run: |
          git clone --branch main \
            git@github.com:cponfick/gh-pages-blog.git \
            "$GITHUB_WORKSPACE/gh-pages-blog"

My complete workflow also handles a repository with no branches, but creating the initial main branch when setting up the public repository keeps the normal deployment path much simpler.

The generated output then replaces the contents of the cloned repository:

      - name: Publish generated files
        run: |
          rsync --archive --delete \
            --exclude='.git/' \
            public/ gh-pages-blog/

          git -C gh-pages-blog config user.name "github-actions[bot]"
          git -C gh-pages-blog config user.email \
            "41898282+github-actions[bot]@users.noreply.github.com"
          git -C gh-pages-blog add --all

          if git -C gh-pages-blog diff --cached --quiet
          then
            echo "No changes to publish"
            exit 0
          fi

          git -C gh-pages-blog commit -m "Publish blog"
          git -C gh-pages-blog push origin main

The important option is rsync --delete. If I remove a page or asset from the Hugo source, the corresponding generated file must also disappear from the public repository. Without --delete, old files could remain publicly accessible after they were removed from the current site.

The .git directory is excluded because it belongs to the deployment repository rather than the generated website. The workflow also avoids empty commits, so each commit in the public repository represents an actual change to the published output.

Configure GitHub Pages #

In the public repository, open Settings → Pages → Build and deployment and configure:

  • Source: Deploy from a branch
  • Branch: main
  • Folder: / (root)

This setting matches the workflow. The contents of Hugo’s public/ directory are copied directly to the root of main, then an ordinary Git push causes GitHub Pages to publish that branch.

The push is made with the SSH deploy key. This detail matters because GitHub documents that commits pushed by a workflow using the repository’s GITHUB_TOKEN do not trigger a Pages build. The deploy key provides cross-repository access and makes the update a normal push to the Pages source branch.

I also keep an empty static/.nojekyll file in the Hugo source. Hugo copies it to the generated root as .nojekyll, which tells branch-based GitHub Pages to serve the existing static output without processing it with Jekyll.

Add a custom domain #

For my custom domain, the Hugo source contains static/CNAME with the hostname:

constantinsblog.eu

Hugo copies this file into public/, and the workflow publishes it at the root of the public repository. This ensures that the domain information remains part of every deployment rather than being removed the next time rsync --delete replaces the generated files.

The corresponding DNS records must point the domain to GitHub Pages. GitHub’s Pages settings show the required records and manage HTTPS after the domain has been verified. The domain itself may still have a registration cost, but the setup does not require a separate virtual machine or web-hosting plan.

Trade-offs and security boundaries #

GitHub also supports artifact-based Pages deployments with actions/upload-pages-artifact and actions/deploy-pages. If the GitHub plan permits Pages deployment from the private source repository, that approach removes the second repository and SSH key.

I chose the separate public repository because it gives me an explicit boundary between private source and public output. It also works when the website must be published from a public repository. The trade-offs are additional setup, generated commits, and responsibility for protecting the deploy key.

Most importantly, repository visibility does not protect anything copied into public/. Drafts, notes, metadata, and static files become public if Hugo includes them in the build. I therefore keep drafts marked appropriately and treat the generated directory as something anyone can inspect.

The resulting boundary #

With this setup, publishing starts with a push to the private repository and ends with GitHub Pages serving a commit from the public repository. The build, validation, and synchronization happen without a deployment command on my laptop and without a server for me to maintain.

The useful boundary is not between a private and public website. It is between private source material and public build output. Hugo creates that boundary, GitHub Actions checks it, and the deployment repository contains only the files GitHub Pages needs to serve.

Constantin Ponfick
Author
Constantin Ponfick
I write about software engineering, cloud development, AI-supported development, and personal projects.