Static Web App mit Microsoft Azure

Azure Subscription

Die Reise beginnt in Azure. Man muss eine Subscription anlegen - dort werden die Zahlungsdaten hinterlegt. Wichtig ist, dass man ein Budget anlegt (Achtung: Es kann bis zu 24h dauern, bis man ein Budget bei einer neuer Subscription anlegen kann).

Microsoft

Repository

Beginnen tut die Reise in Azure DevOps: Falls noch nicht passiert, muss man eine Organisation anlegen.

Microsoft

Genauere Infos gibt es in der Dokumentation.

Als nächstes muss man Azure DevOps und Azure verbinden. Dazu geht man in die Organization Settings. Die TenantId bekommt man aus Microsoft Entra.

Microsoft
Microsoft

Danach einfach ein Project anlegen, welches ein git Repository hat.

Seite mit Hugo bauen

Als erstes muss man sich lokal hugo installieren. Die einfachste Möglichkeit ist natürlich einfach die Exekutable zu laden: https://github.com/gohugoio/hugo/releases.

Danach sind folgende Schritte zu machen:

hugo new site quickstart
cd quickstart
git init
git submodule add https://github.com/theNewDynamic/gohugo-theme-ananke.git themes/ananke
echo "theme = 'ananke'" >> hugo.toml
hugo server

Man kann die Seite dann lokal abrufen: http://localhost:1313/

Danach kann man die Seite ins Repository pushen.

Seite hosten

Und jetzt kommt der spannenste Teil: Wie kann man die Seite hosten. Das geht mit Static Web Apps. Es gibt ein gratis Angebot von 500MB Speicher mit 100GB Transfer - ideal für private Projekte. Bilder kann man ebenfalls hier speichern oder eben in einem Blob Storage. Biceps ist die state-of-the-art Methode für IaC auf Azure. Folgende Scripts sind nicht ideal (ohne Parameter) - genügen aber dem Anspruch eines privaten Mini-Projekts.

main.bicep

targetScope = 'subscription'

resource rgwebsiteprod 'Microsoft.Resources/resourceGroups@2023-07-01' = {
  name: 'rg-website-prod-001'
  location: 'germanywestcentral'
  tags: {}
  properties: {}
}

module storageAccount 'storage.bicep' = {
  name: 'storageModule'
  scope: rgwebsiteprod
  params: {
    storageName: '<storageaccountname>'
    location: rgwebsiteprod.location
  }
}

module staticSite 'webstatic.bicep' = {
  name: 'staticWebsite'
  scope: rgwebsiteprod
  params: {
    staticWebName: '<name>'    
  }
}

storage.bicep

@description('Azure region of the deployment')
param location string = resourceGroup().location

@description('Name of the storage account')
param storageName string

resource webstorage 'Microsoft.Storage/storageAccounts@2023-01-01' = {
  sku: {
    name: 'Standard_LRS'
  }
  kind: 'StorageV2'
  name: storageName
  location: location
  tags: {}
  properties: {
    dnsEndpointType: 'Standard'
    allowedCopyScope: 'AAD'
    defaultToOAuthAuthentication: true
    publicNetworkAccess: 'Enabled'
    allowCrossTenantReplication: false
    isSftpEnabled: false
    minimumTlsVersion: 'TLS1_2'
    allowBlobPublicAccess: true
    allowSharedKeyAccess: false
    isHnsEnabled: true
    networkAcls: {
      ipv6Rules: []
      bypass: 'AzureServices'
      virtualNetworkRules: []
      ipRules: []
      defaultAction: 'Allow'
    }
    supportsHttpsTrafficOnly: true
    encryption: {
      requireInfrastructureEncryption: false
      services: {
        file: {
          keyType: 'Account'
          enabled: true
        }
        blob: {
          keyType: 'Account'
          enabled: true
        }
      }
      keySource: 'Microsoft.Storage'
    }
    accessTier: 'Hot'
  }
}

resource webstorage_default 'Microsoft.Storage/storageAccounts/blobServices@2023-01-01' = {
  parent: webstorage
  name: 'default'
  properties: {
    containerDeleteRetentionPolicy: {
      days: 7
      enabled: true
    }
    cors: {
      corsRules: []
    }
    deleteRetentionPolicy: {
      allowPermanentDelete: false
      days: 7
      enabled: true
    }
  }
  sku: {
    name: 'Standard_LRS'
    tier: 'Standard'
  }
}

resource storageBlobDataContributorRoleDefinition 'Microsoft.Authorization/roleDefinitions@2018-01-01-preview' existing = {
  scope: subscription()
  name: 'ba92f5b4-2d11-453d-a403-e96b0029c9fe'
}

resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  scope: webstorage
  name: guid(resourceGroup().id, '<tenant object id>', storageBlobDataContributorRoleDefinition.id)
  properties: {
    roleDefinitionId: storageBlobDataContributorRoleDefinition.id
    principalId: '<tenant object id>'
    principalType: 'User'
  }
}

resource webstorage_files 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-01-01' = {
  parent: webstorage_default
  name: 'files'
  properties: {
    defaultEncryptionScope: '$account-encryption-key'
    denyEncryptionScopeOverride: false
    publicAccess: 'Blob'
  }
}

resource webstorageFilesImages 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-01-01' = {
  parent: webstorage_default
  name: 'images'
  properties: {
    defaultEncryptionScope: '$account-encryption-key'
    denyEncryptionScopeOverride: false
    publicAccess: 'Blob'
  }
}

webstatic.bicep

@description('The resource name')
param staticWebName string

resource staticSite 'Microsoft.Web/staticSites@2023-01-01' = {
  location: 'West Europe'
  name: staticWebName
  properties: {
    allowConfigFileUpdates: true
    branch: 'master'
    buildProperties: {
      appLocation: 'site'      
      outputLocation: 'public'
    }
    enterpriseGradeCdnStatus: 'Disabled'
    provider: 'Custom'
    repositoryUrl: '<git url>'
    stagingEnvironmentPolicy: 'Enabled'
  }
  sku: {
    name: 'Free'
    tier: 'Free'
  }
}

Und: das war es auch schon. Static Web Apps fügt einen PAK zu dem Dev Ops Projekt hinzu und fügt auch eine Pipline ein, welche die Hugo App baut und published. Zwei Anmerkungen noch:

  • Custom Domains gehen auch mit Biceps - blockieren aber das Deployment Script, bis die Domain verifziert ist. Das verschlingt natürlich Build-Server Minuten
  • Wenn man die Webapp manuell anlegt, dann wird die Buildpipline automatisch konfiguriert - ich weiß nicht, warum er das mit Biceps nicht macht.

Daher die Schritte für die Buildpipline:

Microsoft

name: Azure Static Web Apps CI/CD

pr:
  branches:
    include:
      - master
trigger:
  branches:
    include:
      - master

jobs:
- job: build_and_deploy_job
  displayName: Build and Deploy Job
  condition: or(eq(variables['Build.Reason'], 'Manual'),or(eq(variables['Build.Reason'], 'PullRequest'),eq(variables['Build.Reason'], 'IndividualCI')))
  pool:
    vmImage: ubuntu-latest
  variables:
  - group: Azure-Static-Web-Apps-gray-island-xxxxxx-variable-group
  steps:
  - checkout: self
    submodules: true
  - task: AzureStaticWebApp@0
    inputs:
      azure_static_web_apps_api_token: $(AZURE_STATIC_WEB_APPS_API_TOKEN_GRAY_ISLAND_XXXXXX)
      app_location: "/<folder>" # App source code path
      api_location: ""
      output_location: "public"

Hugo Deepdive

Anbei ein paar wichtige Hilfen

CDN für Bilder

Ich bin kein go-template-experte - aber es geht:

layouts\_default\_markup\render-image.html - siehe auch https://gohugo.io/render-hooks/

{{- $url := urls.Parse .Destination -}}
<img class="content-image"
  {{- if $url.IsAbs }} src="{{ .Destination | safeURL }}"{{ end -}}
  {{- if (not $url.IsAbs) }} src="{{ print (.Page.Site.Params.cdnURL | safeURL) (.Destination | safeURL) }}"{{ end -}}
  {{- with .Text }} alt="{{ . }}"{{ end -}}
  {{- with .Title }} title="{{ . }}"{{ end -}}  
>
{{- /* chomp trailing newline */ -}}

Bilder mit absoluter URL sollen nicht geändert werden. Selbiges für URLs:

{{- $url := urls.Parse .Destination -}}
<a
  {{- if $url.IsAbs }} href="{{ .Destination | safeURL }}"{{ end -}}
  {{- if (not $url.IsAbs) }} href="{{ print (.Page.Site.Params.cdnURL | safeURL) (.Destination | safeURL) }}"{{ end -}}
  {{- with .Title }} title="{{ . }}"{{ end -}}
>
  {{- with .Text | safeHTML }}{{ . }}{{ end -}}
</a>
{{- /* chomp trailing newline */ -}}

Flickr shortcode

Eine einfache Copy & Paste Aufgabe:

Flickr

layouts\shortcodes\flickr.html

<a data-flickr-embed="true" data-header="true" data-footer="true" href="https://www.flickr.com/photos/72225550@N04" title=""><img src="https://live.staticflickr.com/65535/53372707376_9e8ef59edc_b.jpg" width="1024" height="768" alt=""/></a><script async src="//embedr.flickr.com/assets/client-code.js" charset="utf-8"></script>

Google Maps

layouts\shortcodes\gmap.html

<div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
  <iframe src={{.Get "src"}} style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;"></iframe>
</div>

Einbinden

{{< gmap src="https://www.google.com/maps/d/embed?mid=10RcasC0ZMZSQWHyDKJZ2TNfYnMO60UTE" >}}

Es gibt auch die Möglichkeit per <<% was MarkDown rendern würde - aber das funktioniert mit HTML gemischt leider nicht.

Lokal testen

Da meine Seite mini ist, teste ich immer mit

hugo.exe server -s vodepat --disableFastRender --renderToMemory

Fazit

Azure Static Web Apps bieten eine super einfache Lösung - inkl. CI/CD. Für kleine Projekte gratis. Azure Blob Storage ist natürlich immer so ne Sache - man müsste Azure Front Door für CDN und DDoS Schutz dazukaufen - was den Preis allerdings gewaltig in die Höhe treibt.