Skip to main content
  1. Blog/

rdgy.at – Hugo, Blowfish, OpenTofu, Azure and GitHub Actions in Two Hours

Dominic Rudigier
Author
Dominic Rudigier
BSc MSc Scrum Master
I help teams turn unclear technical problems into working systems, reliable operations, and practical outcomes.

This is how I built rdgy.at – and how you can replicate it:

  • Hugo for the site
  • Blowfish for the theme
  • Azure Static Web Apps for hosting
  • OpenTofu for infrastructure
  • GitHub Actions for automated deployment

The final URL structure looks like this:

www.example.com/        -> homepage
www.example.com/blog/   -> blog
www.example.com/projects/
www.example.com/services/
example.com             -> forwards to https://www.example.com

If you need this setup for your own business – whether as a simple blog, company website, or a standalone product like Belegmappe.at – get in touch at office@rdgy.at. We’ll talk through what you need, and I’ll be straight with you about whether and how I can help.

German is the default language. Content is written first under content/de, then translated into content/en.


Phase 1 – Build the Site Locally
#

1. Install the tools
#

You need:

  • Hugo Extended
  • Git
  • OpenTofu
  • Azure CLI

On macOS one command is enough:

brew install hugo opentofu azure-cli

Then verify everything installed correctly:

hugo version
tofu version
az version

2. Create the site and add Blowfish
#

hugo new site my-site
cd my-site
git init

git submodule add -b main \
  https://github.com/nunocoracao/blowfish.git \
  themes/blowfish

mkdir -p config/_default
cp themes/blowfish/config/_default/*.toml config/_default/

3. Configure Hugo
#

The two most important config files:

  • config/_default/hugo.toml – general settings, languages, navigation
  • config/_default/params.toml – layout and appearance

config/_default/hugo.toml
#

theme = "blowfish"
baseURL = "https://www.example.com/"
defaultContentLanguage = "de"
defaultContentLanguageInSubdir = false

enableRobotsTXT = true
summaryLength = 0

[outputs]
  home = ["HTML", "RSS", "JSON"]

[taxonomies]
  tag = "tags"
  category = "categories"

[languages]
  [languages.en]
    weight = 2
    contentDir = "content/en"
  [languages.de]
    weight = 1
    contentDir = "content/de"

[languages.en.menu]
  [[languages.en.menu.main]]
    name = "Blog"
    pageRef = "blog"
    weight = 10
  [[languages.en.menu.main]]
    name = "Projects"
    pageRef = "projects"
    weight = 20
  [[languages.en.menu.main]]
    name = "Services"
    pageRef = "services"
    weight = 30

[languages.de.menu]
  [[languages.de.menu.main]]
    name = "Blog"
    pageRef = "blog"
    weight = 10
  [[languages.de.menu.main]]
    name = "Projekte"
    pageRef = "projects"
    weight = 20
  [[languages.de.menu.main]]
    name = "Leistungen"
    pageRef = "services"
    weight = 30

A few things to note:

  • baseURL always points to www
  • German lives at /, English at /en/
  • Navigation is defined per language here

config/_default/params.toml
#

defaultBackgroundImage = "img/background.jpg"

[homepage]
  layout = "background"
  homepageImage = "img/background.jpg"
  showRecent = true
  showRecentItems = 3

[article]
  showDate = true
  showAuthor = true
  showReadingTime = true
  showTableOfContents = true
  showTaxonomies = true
  showPagination = true
  showHero = true
  heroStyle = "thumbAndBackground"

[list]
  showHero = true
  heroStyle = "background"
  showSummary = true
  showCards = true
  cardView = true

This gives you:

  • Homepage with background image
  • Hero sections on blog and project pages
  • Latest posts on the homepage
  • Card view for blog and projects

4. Set up the content structure
#

Sections (blog, projects) get a _index.md. Individual pages (posts, projects) are regular .md files.

mkdir -p content/de/blog
mkdir -p content/de/projects
mkdir -p content/en/blog
mkdir -p content/en/projects
mkdir -p static/img

The structure looks like this:

content/
├── de/
│   ├── _index.md
│   ├── blog/
│   │   ├── _index.md
│   │   └── first-post/index.md
│   ├── projects/
│   │   ├── _index.md
│   │   └── product-a.md
│   └── services.md
└── en/
    ├── _index.md
    ├── blog/
    ├── projects/
    └── services.md

Homepage example
#

content/de/_index.md

---
title: "Dominic"
description: "Cloud engineer from Innsbruck."
---

I build cloud infrastructure for teams that prefer to hand off
production deployments to someone experienced.

Blog index example
#

content/de/blog/_index.md

---
title: "Blog"
description: "Notes on Cloud, Infrastructure as Code and Software Delivery."
---

Project page example
#

content/de/projects/product-a.md

---
title: "Product A"
description: "Short product description."
summary: "Short text for the project card in the overview."
showDate: false
showReadingTime: false
---

## Idea

What the product is about.

## Challenges

- Challenge one
- Challenge two

## Tech Stack

- Hugo
- PostgreSQL
- Azure

## Live Project

[product-a.com](https://product-a.com)

Write the German content first, then create the matching translation under content/en/.

5. Test the site locally
#

hugo server

Open in your browser:

  • http://localhost:1313/ – German version
  • http://localhost:1313/en/ – English version

Once the site looks the way you want locally, move on to deployment.


Phase 2 – Infrastructure with OpenTofu
#

OpenTofu describes the Azure infrastructure as code. Write it once, reuse and version it forever.

6. Provision Azure with OpenTofu
#

Create an infra/ directory in the project root and add the four files below. Then get your credentials and roll everything out with tofu apply.

mkdir infra

infra/variables.tf
#

This file declares all the inputs OpenTofu needs. The actual values don’t live here – they come from terraform.tfvars. This keeps the structure and the secrets cleanly separated.

sensitive = true tells OpenTofu never to print these values in the terminal – not even during apply.

location has a default value (westeurope) that is used unless you specify otherwise.

variable "subscription_id" {
  type      = string
  sensitive = true
}

variable "client_id" {
  type      = string
  sensitive = true
}

variable "client_secret" {
  type      = string
  sensitive = true
}

variable "tenant_id" {
  type      = string
  sensitive = true
}

variable "location" {
  type    = string
  default = "westeurope"
}

infra/main.tf
#

This is the actual infrastructure. OpenTofu reads this file and creates the described resources in Azure – or updates them if they already exist.

terraform block – specifies the provider version. azurerm is the official Azure plugin. Version ~> 3.0 allows all patch updates within version 3, but no major version jump.

provider "azurerm" – connects OpenTofu to your Azure account using the variables above.

azurerm_resource_group – a resource group is a logical container in Azure. All related resources land in it. Delete the group and everything inside it goes with it.

azurerm_static_web_app – the actual hosting. Azure Static Web Apps serves the site, distributes it globally via CDN, and handles HTTPS automatically. The Free tier is fully sufficient for a regular website.

azurerm_static_web_app_custom_domain – links your own domain (www.example.com) to the Static Web App. cname-delegation means Azure checks that the CNAME record in DNS points to the Azure hostname before activating the domain.

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.0"
    }
  }
  required_version = ">= 1.6.0"
}

provider "azurerm" {
  features {}
  subscription_id = var.subscription_id
  client_id       = var.client_id
  client_secret   = var.client_secret
  tenant_id       = var.tenant_id
}

resource "azurerm_resource_group" "site" {
  name     = "rg-my-site"
  location = var.location
}

resource "azurerm_static_web_app" "site" {
  name                = "stapp-my-site"
  resource_group_name = azurerm_resource_group.site.name
  location            = azurerm_resource_group.site.location
  sku_tier            = "Free"
  sku_size            = "Free"
}

resource "azurerm_static_web_app_custom_domain" "www" {
  static_web_app_id = azurerm_static_web_app.site.id
  domain_name       = "www.example.com"
  validation_type   = "cname-delegation"
}

infra/outputs.tf
#

After apply, OpenTofu prints these values in the terminal. You’ll need them for the next steps – domain setup and the GitHub secret.

  • site_url – the auto-generated Azure URL for your site (e.g. https://proud-rock-abc123.azurestaticapps.net). Use this to verify the deployment worked before pointing your domain.
  • deployment_token – the secret key GitHub Actions needs to upload files to Azure. Comes next as a GitHub secret.
  • www_cname_target – the hostname your www CNAME record at your DNS provider needs to point to.
output "site_url" {
  value = "https://${azurerm_static_web_app.site.default_host_name}"
}

output "deployment_token" {
  value     = azurerm_static_web_app.site.api_key
  sensitive = true
}

output "www_cname_target" {
  value = azurerm_static_web_app.site.default_host_name
}

Get your Azure credentials
#

Before filling in terraform.tfvars, you need the four values from Azure. One-time setup:

1. Log in to Azure

az login

A browser window opens – sign in with your Microsoft account.

2. Get the Subscription ID

az account show --query id -o tsv

Use this value as subscription_id.

3. Create a Service Principal

A service principal is a technical account that OpenTofu uses to create resources in Azure – think of it like an API key, but for Azure.

az ad sp create-for-rbac \
  --name "sp-my-site" \
  --role Contributor \
  --scopes /subscriptions/<YOUR_SUBSCRIPTION_ID>

The output looks like this:

{
  "appId":       "...",   <- this is client_id
  "password":    "...",   <- this is client_secret
  "tenant":      "..."    <- this is tenant_id
}

The secret is only shown once – save it immediately.

infra/terraform.tfvars
#

Now fill in the four values. This file contains real credentials – it must never go into the Git repository. Add it to .gitignore right away:

echo "infra/terraform.tfvars" >> .gitignore
subscription_id = "..."
client_id       = "..."
client_secret   = "..."
tenant_id       = "..."

Apply the infrastructure
#

cd infra
tofu init
tofu apply
cd ..

tofu init downloads the Azure provider. tofu apply shows you a preview of all resources to be created first – confirm with yes.


Phase 3 – CI/CD with GitHub Actions
#

From here on, every deployment runs automatically: push to main → Hugo builds the site → Azure publishes it.

7. Set up the GitHub repository
#

If you don’t have one yet, create an empty repository on github.com. Then connect it locally:

git remote add origin https://github.com/<your-username>/my-site.git

8. Configure GitHub Actions
#

Create .github/workflows/deploy.yml:

name: Deploy rdgy.at to Azure

on:
  push:
    branches: [main]

jobs:
  build_and_deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          submodules: true
          fetch-depth: 0

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

      - name: Build
        run: hugo --minify

      - name: Deploy to Azure Static Web Apps
        uses: Azure/static-web-apps-deploy@v1
        with:
          azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
          repo_token: ${{ secrets.GITHUB_TOKEN }}
          action: "upload"
          app_location: "public"
          output_location: ""
          skip_app_build: true

Hugo builds the site into public/ – the deploy step uploads that folder directly to Azure.

9. Add the GitHub secret
#

Read the deployment token from OpenTofu:

cd infra
tofu output -raw deployment_token
cd ..

Add it as a secret in GitHub:

  • Repository → Settings → Secrets and variables → Actions → New repository secret

Name:

AZURE_STATIC_WEB_APPS_API_TOKEN

Phase 4 – Domain & Go Live
#

10. Configure the domain
#

Step 1: Get the CNAME target

cd infra
tofu output www_cname_target
cd ..

The output looks something like:

proud-rock-abc123.azurestaticapps.net

Step 2: Set the DNS records

At your DNS provider (e.g. Cloudflare, Namecheap, Google Domains) create two records:

TypeNameValue
CNAMEwwwproud-rock-abc123.azurestaticapps.net
Redirect / ALIAS@ (Apex)https://www.example.com

The www record connects your domain directly to Azure. The apex record (example.com without www) is set as an HTTP redirect – most DNS providers call it “Redirect” or “URL Forward”.

Step 3: Wait for domain validation

Azure automatically checks whether the CNAME record is set correctly (cname-delegation from main.tf). This can take a few minutes to an hour depending on your DNS provider. HTTPS is then activated automatically – no manual certificate request needed.

Check the status:

az staticwebapp hostname list --name stapp-my-site --resource-group rg-my-site

Once provisioningState shows Succeeded, the domain is live.

11. Push and go live
#

git add .
git commit -m "feat: initial site"
git push origin main

GitHub Actions kicks off automatically, builds the site, and deploys it to Azure Static Web Apps.


Summary
#

PhaseWhat happens
1 – LocalSet up Hugo + Blowfish, write content, test locally
2 – IaCCreate Azure infrastructure with OpenTofu
3 – CI/CDSet up GitHub Actions for automated deployment
4 – LiveConfigure the domain, make the first push

The result is a fast, static website with a blog, projects, and bilingual routing – no backend required.