Contents

How I Set Up My Blog: A Developer's Journey to Efficiency and Simplicity

Requirements

When I decided to set up my own blog, my primary goals were low maintenance and minimal running costs, possibly even free. With these requirements in mind, I chose to use a static content generator, eliminating the need for hosting a database or performing software upgrades. Additionally, I wanted an easy way to write articles and nicely format code samples.

After some research, I opted to use Hugo. It ticked all the boxes for my needs: easy article writing in Markdown (so I wouldn’t have to worry about CSS or HTML), excellent syntax highlighting support, comprehensive and understandable documentation, and cross-platform compatibility (essential for a Mac user like me). Since articles are written in Markdown, I can use any editor that supports it.

./images/markdown-sample.png
Markdown editor with a Hugo article in progress

I’m using the LoveIt theme for its minimalist look, which was exactly what I was after.

./images/loveit-theme.png
A view of the LoveIt theme in action

Folder Structure

I chose to use Page Bundles for my folder structure. This allows me to keep all the content for an article within its respective folder. I also decided to prefix folder names with the date and then the article title for organization and ease of finding. The “slug” in Front Matter is used to create user- and SEO-friendly URLs.

./images/folder-structure.png
Folder structure, highlighting the naming convention and organization

Hosting and Content Management

I use Azure DevOps daily, so it was natural for me to keep all the content there. I also use GitHub, mainly for larger code samples.

For hosting, I chose Azure Static Web Apps. Its free tier is perfect for hobby or personal projects, and deployment is a breeze. Since I’m using a .dev domain, I needed an SSL certificate, which Azure Static Web Apps provides for free. As of writing, it includes 100GB of bandwidth, which should suffice for now.

Build and Deployment Process

My experience with Azure DevOps CI/CD pipelines made the deployment process straightforward, involving a simple two-stage YAML pipeline with build and deployment stages.

The build process is quite simple:

  1. Install Hugo.
  2. Optimize Images.
  3. Build the site.
  4. Publish the produced artifacts.

./images/image-optimisation-in-pipeline.png
Part of the Azure DevOps pipeline showing the image optimization step

Building the Container Image

I use (lighttpd) as a base for my image. It’s fast and lightweight, allowing me to test my site locally with a simple Docker Compose file.

version: "3"

services:
    lighttpd:
        image: sebp/lighttpd
        volumes:
            - ./public:/var/www/localhost/htdocs
        ports:
            - 8080:80
        tty: true

I also build a multi-architecture image to run my site on my Raspberry Pi k8s cluster at home. I’ve considered hosting my site on this home cluster, and it might be a future consideration.

The Dockerfile requires few steps only

FROM sebp/lighttpd:latest

# Copy the files from the public folder to the htdocs folder
COPY public/ /var/www/localhost/htdocs/

EXPOSE 80

CMD ["start.sh"]

Why Optimize Images During Build?

Although Hugo has built-in image processing, I couldn’t make it work with the LoveIt theme. As I use the “windows-latest” image of Azure DevOps agent for the build stage, I can easily optimize images during this phase. Each agent comes with ImageMagick preinstalled, so I utilize it to optimize all images.

- task: PowerShell@2
  displayName: 'Optimize images'
  inputs:
    targetType: 'inline'
    script: |
      # Define the directories and their maximum image sizes
      $ImageDirectories = @{
          "$(Build.SourcesDirectory)\content" = "1024x1024";
          "$(Build.SourcesDirectory)\assets" = "256x256";
      }

      # Define the file formats to optimize
      $FileFormats = @("*.jpg", "*.jpeg", "*.png")

      # Loop through all specified directories and their sizes
      foreach ($Directory in $ImageDirectories.Keys) {
          $MaxSize = $ImageDirectories[$Directory]

          # Check if the directory exists
          if (Test-Path -Path $Directory) {
              # Loop through all specified file formats in the current directory
              foreach ($Format in $FileFormats) {
                  # Find images and process each one
                  Get-ChildItem -Path $Directory -Recurse -Filter $Format | ForEach-Object {
                      $ImagePath = $_.FullName
                      $TempPath = "$ImagePath.tmp"

                      # Copy the original image to a temporary file
                      Copy-Item -Path $ImagePath -Destination $TempPath

                      Write-Host "Optimizing $ImagePath to a maximum size of $MaxSize"

                      # ImageMagick command to resize and compress the temporary image
                      magick convert $TempPath -resize $MaxSize^> -quality 85 $TempPath

                      # Compare the file sizes
                      $OriginalSize = (Get-Item $ImagePath).Length
                      $OptimizedSize = (Get-Item $TempPath).Length

                      if ($OptimizedSize -lt $OriginalSize) {
                          # Replace original with optimized image if smaller
                          Move-Item -Path $TempPath -Destination $ImagePath -Force
                      } else {
                          # Delete the temporary file if it's not smaller
                          Remove-Item -Path $TempPath
                      }
                  }
              }
          } else {
              Write-Host "Directory not found: $Directory"
          }
      }      
    pwsh: true
    workingDirectory: '$(Build.SourcesDirectory)'

Azure Static Web App Configuration

By default, deploying to Azure Static Web Apps presents a challenge with handling 404 errors and URL formatting. If a user navigates to a non-existent URL, they encounter a default 404 page provided by Azure, which might not align with your site’s theme or structure. Additionally, the lack of proper configuration for trailing slashes can adversely impact SEO.

Configuring Azure Static Web Apps is crucial to address these issues. Detailed guidance can be found on the Configuration overview page. To ensure that your custom 404.html file is utilized and trailing slashes are correctly handled, you should add a staticwebapp.config.json file under the “static” folder. The content of this file should be structured as follows:

{
  "trailingSlash": "auto",
  "responseOverrides": {
    "404": {
      "rewrite": "/404.html"
    }
  }
}

Publishing The Produced Artifacts

The publishing process involves installing the SWA CLI, verifying the installation, and running the swa deploy CLI command. The entire Azure DevOps YAML pipeline is available for reference.

# Starter pipeline
# Start with a minimal pipeline that you can customize to build and deploy your code.
# Add steps that build, run tests, deploy, and more:
# https://aka.ms/yaml
trigger:
- feature/*
- master
- main

name: 0.$(Date:yy)$(DayOfYear).$(Rev:r)

variables:
  - name: PackageVersion
    value: '$(Build.BuildNumber)'
  - group: MyBlog

stages:
  - stage: Build
    displayName: 'Build'
    jobs:
      - job: BuildSite
        pool:
          vmImage: 'windows-latest'
        displayName: 'Build Site'
        steps:
          - checkout: self
            persistCredentials: true
            clean: true
            submodules: recursive

          - task: Bash@3
            displayName: 'Install Hugo'
            inputs:
              targetType: 'inline'
              script: |
                choco install hugo-extended                


          - task: PowerShell@2
            displayName: 'Optimize images'
            inputs:
              targetType: 'inline'
              script: |
                # Define the directories and their maximum image sizes
                $ImageDirectories = @{
                    "$(Build.SourcesDirectory)\content" = "1024x1024";
                    "$(Build.SourcesDirectory)\assets" = "256x256";
                }

                # Define the file formats to optimize
                $FileFormats = @("*.jpg", "*.jpeg", "*.png")

                # Loop through all specified directories and their sizes
                foreach ($Directory in $ImageDirectories.Keys) {
                    $MaxSize = $ImageDirectories[$Directory]

                    # Check if the directory exists
                    if (Test-Path -Path $Directory) {
                        # Loop through all specified file formats in the current directory
                        foreach ($Format in $FileFormats) {
                            # Find images and process each one
                            Get-ChildItem -Path $Directory -Recurse -Filter $Format | ForEach-Object {
                                $ImagePath = $_.FullName
                                $TempPath = "$ImagePath.tmp"

                                # Copy the original image to a temporary file
                                Copy-Item -Path $ImagePath -Destination $TempPath

                                Write-Host "Optimizing $ImagePath to a maximum size of $MaxSize"

                                # ImageMagick command to resize and compress the temporary image
                                magick convert $TempPath -resize $MaxSize^> -quality 85 $TempPath

                                # Compare the file sizes
                                $OriginalSize = (Get-Item $ImagePath).Length
                                $OptimizedSize = (Get-Item $TempPath).Length

                                if ($OptimizedSize -lt $OriginalSize) {
                                    # Replace original with optimized image if smaller
                                    Move-Item -Path $TempPath -Destination $ImagePath -Force
                                } else {
                                    # Delete the temporary file if it's not smaller
                                    Remove-Item -Path $TempPath
                                }
                            }
                        }
                    } else {
                        Write-Host "Directory not found: $Directory"
                    }
                }                
              pwsh: true
              workingDirectory: '$(Build.SourcesDirectory)'

          - task: Bash@3
            displayName: 'Build site'
            inputs:
              targetType: 'inline'
              script: |
                hugo
                dir                
              workingDirectory: '$(Build.SourcesDirectory)'

          - task: CopyFiles@2
            inputs:
              SourceFolder: '$(Build.SourcesDirectory)'
              Contents: |
                public/**
                Dockerfile                
              TargetFolder: '$(Build.ArtifactStagingDirectory)'
              CleanTargetFolder: true

          - task: PublishPipelineArtifact@1
            inputs:
              targetPath: '$(Build.ArtifactStagingDirectory)'
              artifact: 'public'
              publishLocation: 'pipeline'

      - job: BuildImage
        dependsOn:
          - BuildSite
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - checkout: none
          - task: DownloadPipelineArtifact@2
            inputs:
              buildType: 'current'
              artifactName: 'public'
              targetPath: '$(Build.ArtifactStagingDirectory)'

          - task: Docker@2
            inputs:
              containerRegistry: 'DockerRegistry'
              command: 'login'

          - task: CmdLine@2
            displayName: 'Set things'
            inputs:
              script: |
                docker run --privileged --rm tonistiigi/binfmt --install arm64
                docker run --privileged --rm tonistiigi/binfmt
                docker buildx create --use                

          - task: CmdLine@2
            displayName: 'Build Dockerfile'
            inputs:
              script: |
                docker buildx build --platform 'linux/amd64,linux/arm64' \
                  -t $(CONTAINER_REGISTRY_NAME).azurecr.io/myblog:$(Build.BuildNumber) \
                  -t $(CONTAINER_REGISTRY_NAME).azurecr.io/myblog:latest \
                  -f ./Dockerfile \
                  --push \
                  .                  
              workingDirectory: '$(Build.ArtifactStagingDirectory)'
  - stage: Deploy
    dependsOn:
      - Build
    jobs:
      - deployment: DeployToStaticWebApp
        displayName: Deploy to Azure Static Web App
        pool:
          vmImage: 'ubuntu-latest'
        environment: MyBlog
        strategy:
          runOnce:
            deploy:
              steps:
                - checkout: none
                - download: none
                - task: DownloadPipelineArtifact@2
                  inputs:
                    buildType: 'current'
                    artifactName: 'public'
                    targetPath: '$(Build.ArtifactStagingDirectory)'
                - task: Bash@3
                  displayName: Install SWA CLI
                  inputs:
                    targetType: 'inline'
                    script: |
                      npm install -g @azure/static-web-apps-cli                      
                - task: Bash@3
                  displayName: Validate SWA install
                  inputs:
                    targetType: 'inline'
                    script: |
                      swa --version                      
                - task: Bash@3
                  displayName: Deploy
                  inputs:
                    workingDirectory: '$(Build.ArtifactStagingDirectory)'
                    targetType: 'inline'
                    script: |
                      swa deploy ./public --deployment-token $(SWA_CLI_DEPLOYMENT_TOKEN) --env Production                      

All variables come from the “MyBlog” variable group, where I keep the deployment token and the ACR name.

Cheers, Andrzej