The personal blog and portfolio of Russ Rimmerman, Microsoft Cloud Solution
Architect. Built with Astro and Tailwind CSS,
deployed to Azure Static Web Apps.
The deployable Static Web Apps configuration lives in
public/staticwebapp.config.json so Astro
copies it into dist/ for prebuilt deployments.
- ⚡ Astro static site — near-zero JavaScript, fast and SEO-friendly
- ✍️ Blog authored in Markdown/MDX via Astro Content Collections
- 🗂️ Projects/portfolio showcase
- 🏷️ Tags & topic pages
- 🌗 Dark/light mode with no-flash theme loading
- 💬 Giscus comments on blog posts (GitHub Discussions)
- 📰 RSS feed, sitemap, canonical URLs, Open Graph cards, and JSON-LD
- 📧 Newsletter signup with server-side provider configuration and availability checks
- ♿ Skip links, keyboard navigation, reduced-motion support, and responsive images
- 🔒 Privacy disclosure, opt-in comment loading, CSP, and security headers
- 🖼️ AI-generated, topic-aware photorealistic enterprise post hero images (Entra ID / OIDC — no API key)
- 🎨 Azure-inspired design system
- Node.js ≥ 24 (matches the version used in CI — see
.github/workflows/*.yml). - npm ≥ 10 (bundled with Node 24).
Verify your local versions:
node --version # v24.x or newer
npm --version # 10.x or newerIf you need to switch Node versions, nvm,
fnm, or Volta all work.
npm install
npm run dev # start the dev server at http://localhost:4321| Command | Description |
|---|---|
npm run dev |
Start the local dev server on http://localhost:4321 (host-exposed). |
npm run start |
Alias for npm run dev — same host-exposed dev server. |
npm run build |
Type-check with astro check and build the production site to dist/. |
npm run check:site |
Check generated metadata, structured data, headings, links, images, sitemap exclusions, and required files. |
npm test |
Build the production site and run all generated-site checks. |
npm run preview |
Preview the production build locally. |
npm run astro |
Passthrough to the Astro CLI (npm run astro -- <cmd>). |
npm run hero |
Generate topic-aware AI hero images for posts (see Auto-generating blog hero images). |
npm run format |
Format the maintained source and configuration paths listed in package.json; editorial content and skill files outside those paths are intentionally excluded. |
Create a Markdown file in src/content/blog/:
---
title: "My Post Title"
description: "A short summary used for cards, SEO, and RSS."
pubDate: 2026-07-08
tags:
[
"Azure",
"AI",
"AI Foundry",
"Agents",
"MCP",
"Copilot",
"Copilot Studio",
"Microsoft Graph",
"Intune",
"Power Platform",
"Identity",
"Security",
"Vibe Coding",
"Tools",
"Design",
"Microsoft",
"Getting Started",
"Projects",
]
featured: false # set true to surface on the home page
draft: false # set true to hide from the site
---
Your content here…A post can carry its own heroImage (used on cards and the article header). You
can supply one yourself, or auto-generate a topic-aware photorealistic
enterprise image — see Auto-generating blog hero images.
When you set heroImage, the content schema also requires a concise heroAlt
description of what the image communicates.
Posts without an image fall back to a branded placeholder, so nothing looks
broken in the meantime.
Add a project by creating a Markdown file in src/content/projects/.
Site-wide values live in src/consts.ts: title, tagline,
social links, and Giscus settings.
- Push this repo to GitHub as a public repository.
- Enable Discussions on the repo (Settings → General → Features).
- Install the giscus GitHub App on the repo.
- Visit giscus.app, enter your repo, and copy the
generated
repo,repoId,category, andcategoryIdvalues. - Paste them into the
GISCUSobject insrc/consts.ts.
Until configured, blog posts show a friendly placeholder instead of comments.
The signup form (src/components/NewsletterSignup.astro) posts to an Azure
Static Web Apps managed API function at /api/subscribe
(api/src/functions/subscribe.js), which forwards the address to your provider.
The provider API key stays server-side and is never exposed to the browser.
Configure Buttondown by adding these values in the Static Web App under Settings → Environment variables (Application settings):
Buttondown
| Name | Value |
|---|---|
NEWSLETTER_PROVIDER |
buttondown |
BUTTONDOWN_API_KEY |
your Buttondown API token |
ALLOWED_ORIGINS |
every origin the site is served from, comma-separated |
ALLOWED_ORIGINS is required, not optional. Static Web Apps proxies /api/* to
a managed Functions host and does not pass the public hostname through, so the
function cannot work out which origin the browser actually used. Without this
setting every browser submission is rejected as cross-origin and the form shows
a generic error. Include the *.azurestaticapps.net default hostname alongside
the custom domain, for example:
https://www.russrimmerman.com,https://russrimmerman.com,https://<name>.azurestaticapps.net
Until the provider is configured the endpoint returns a friendly "not available yet" message rather than silently discarding the signup. The form validates the email, includes a honeypot for bots, shows accessible loading/success/error states, and only reports success when the provider confirms it.
To test the function locally, run the SWA CLI (swa start dist --api-location api) after npm run build; the plain astro dev server does not serve /api.
Each blog post can have a heroImage. Instead of designing one by hand, the
npm run hero generator produces a topic-aware, photorealistic "Portal 360"
enterprise hero image: it reads the post's title, summary, and tags, maps them
to a cinematic enterprise scene (e.g. Copilot Studio → an intelligent business
operations center orchestrating AI agents; Identity → a secure identity gateway
verifying tokens), sends the prompt to Azure OpenAI (gpt-image-1), optimizes
the result to a WebP in src/assets/blog/, and writes the heroImage +
heroAlt frontmatter for you. Images are pure text-to-image — no headshot,
logos, or on-image text.
Why this is more secure: authentication is Microsoft Entra ID (OIDC), not an API key.
DefaultAzureCredentialobtains a short-lived, RBAC-scoped access token (az loginlocally; federated OIDC in CI). There is no long-lived key to commit, leak, or rotate, and access is governed by a role assignment you can revoke at any time. It also works in tenants where key-based auth is disabled.
This is deliberately a local / CI generator, never a page-build step \u2014 image generation costs money per call and is non-deterministic. Generate once, commit the static WebP, and every site build just serves the file.
-
Configure the endpoint + deployment (non-secret). Locally, add them to
.env(already gitignored):AZURE_OPENAI_ENDPOINT=https://<resource>.openai.azure.com AZURE_OPENAI_IMAGE_DEPLOYMENT=<your-gpt-image-1-deployment-name>
-
Grant access. Assign the identity you sign in as the
Cognitive Services OpenAI Userrole on the Azure OpenAI resource. No key required. -
Sign in:
az login(device code in headless environments:az login --use-device-code).
npm run hero -- <post-slug> # one post, e.g. vibe-coding-lessons
npm run hero -- <post-slug> --dry-run # preview the prompt only (no token/API call)
npm run hero -- <post-slug> --force # overwrite an existing image
npm run hero -- --missing # every published post that lacks an image
npm run hero -- --missing --include-drafts # ...including drafts
npm run hero -- --list-missing # just list posts without an imageReview the generated WebP, then commit it alongside the post. To change a
post's scene, edit the TOPIC_SCENES map in
scripts/generate-hero.mjs or reorder the post's
tags (the most specific matching topic wins).
The workflow at
.github/workflows/generate-hero-images.yml
runs whenever a post is pushed to main without a heroImage (or on demand via
Run workflow). It logs in with the same Entra ID / OIDC federated identity
as the deploy workflow \u2014 no secrets \u2014 generates the missing images, and commits
them back.
To enable it:
-
Reuse the deploy identity (see Deploying to Azure Static Web Apps) and additionally grant it the
Cognitive Services OpenAI Userrole on the Azure OpenAI resource:OPENAI_ID=$(az cognitiveservices account show --name "<openai-resource>" --resource-group "<rg>" --query id -o tsv) APP_ID=$(az ad app list --display-name "russrimmerman-blog-deploy" --query "[0].appId" -o tsv) az role assignment create --assignee "$APP_ID" --role "Cognitive Services OpenAI User" --scope "$OPENAI_ID"
-
Add two repo Variables (not secrets \u2014 they're not sensitive) under Settings \u2192 Secrets and variables \u2192 Actions \u2192 Variables:
AZURE_OPENAI_ENDPOINTandAZURE_OPENAI_IMAGE_DEPLOYMENT. The identity secrets (AZURE_CLIENT_ID,AZURE_TENANT_ID,AZURE_SUBSCRIPTION_ID) are shared with the deploy workflow.
The auto-commit is made with the built-in
GITHUB_TOKEN, which by design does not re-trigger other workflows \u2014 so the deploy workflow won't run on that commit. Re-run the deploy manually, or push any follow-up change, to publish the new images.
A GitHub Actions workflow is included at
.github/workflows/azure-static-web-apps.yml.
It signs in to Azure with Microsoft Entra ID via OIDC (federated
credentials) — so no long-lived secret is ever stored in GitHub. It then
uses that short-lived, least-privilege sign-in to read the Static Web App's
deployment token at runtime and publish. Nothing sensitive is committed,
stored as a repo secret, or left to rotate.
Sign in to Azure and GitHub, then run the provisioning script. It's idempotent (safe to re-run) and creates everything: the resource group, the Static Web App, the deployment identity, its federated credentials, a custom least-privilege role, and all the GitHub configuration.
az login # or: az login --use-device-code (headless containers)
gh auth login # needs the `repo` scope + repo admin
./scripts/setup-azure-swa.shOverride any default with an environment variable:
SWA_NAME=my-blog RESOURCE_GROUP=my-blog-rg LOCATION=westeurope \
./scripts/setup-azure-swa.shThen push to main — the workflow builds and deploys automatically. That's it.
Forked-PR preview environments still won't deploy: pull requests from forks can't obtain an OIDC token. Pushes to
mainand PRs from branches in this repo work.
| # | Resource | Why it exists |
|---|---|---|
| 1 | Resource group | A container so every resource is managed and deleted together. |
| 2 | Static Web App (Free) | Hosts the built site and the /api functions. |
| 3 | Entra app registration + service principal | The identity GitHub Actions "becomes" — scoped, auditable, revocable. |
| 4 | Two federated credentials | Let GitHub prove its identity to Azure with a short-lived OIDC token instead of a secret. One trusts the main branch, one trusts pull requests. |
| 5 | Custom least-privilege role | Grants the identity exactly one permission — read this one SWA's deployment token — and nothing else. |
| 6 | 3 secrets + 2 variables | Non-sensitive identifiers telling the workflow which identity to use and which SWA to deploy to. |
You don't have to run these commands by hand. Paste the prompt below into your coding agent (GitHub Copilot, etc.) and let it execute the CLI for you. First do the two things an agent cannot do for you (see What the agent can't do below): sign in to Azure and GitHub. Once you're signed in, the agent can run everything else.
Prompt to give your agent:
"Set up OIDC deployment for this repo's Azure Static Web App. I've already run
az loginandgh auth login. Run./scripts/setup-azure-swa.sh(ask me for the Static Web App name, resource group, and region first if they aren't the defaults). Show me each command before you run it and stop if any command fails."
If you'd rather run the steps by hand instead of the script, these are the exact commands it runs:
APP_NAME="russrimmerman-blog-deploy"
RESOURCE_GROUP="<your-resource-group>"
SWA_NAME="<your-static-web-app-name>"
REPO="russrimm/Russ-Rimmerman-Blog"
# 1. Create the app registration + service principal (the CI/CD identity)
az ad app create --display-name "$APP_NAME"
APP_ID=$(az ad app list --display-name "$APP_NAME" --query "[0].appId" -o tsv)
az ad sp create --id "$APP_ID"
# 2. Federated credentials — one for the main branch, one for pull requests.
# This is how GitHub proves its identity to Azure with no stored secret.
az ad app federated-credential create --id "$APP_ID" --parameters '{
"name": "github-main-branch",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:russrimm/Russ-Rimmerman-Blog:ref:refs/heads/main",
"audiences": ["api://AzureADTokenExchange"]
}'
az ad app federated-credential create --id "$APP_ID" --parameters '{
"name": "github-pull-requests",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:russrimm/Russ-Rimmerman-Blog:pull_request",
"audiences": ["api://AzureADTokenExchange"]
}'
# 3. Custom least-privilege role — the identity can ONLY read this one SWA's
# deployment token, nothing else. The workflow reads that token at runtime.
SWA_ID=$(az staticwebapp show --name "$SWA_NAME" --resource-group "$RESOURCE_GROUP" --query id -o tsv)
az role definition create --role-definition "{
\"Name\": \"SWA Deployment Token Reader ($SWA_NAME)\",
\"IsCustom\": true,
\"Description\": \"Read one Static Web App's deployment token for CI/CD.\",
\"Actions\": [\"Microsoft.Web/staticSites/read\", \"Microsoft.Web/staticSites/listSecrets/action\"],
\"AssignableScopes\": [\"$SWA_ID\"]
}"
az role assignment create --assignee "$APP_ID" --role "SWA Deployment Token Reader ($SWA_NAME)" --scope "$SWA_ID"
# 4. Store identifiers (not access keys) as GitHub Actions secrets + variables
gh secret set AZURE_CLIENT_ID --repo "$REPO" --body "$APP_ID"
gh secret set AZURE_TENANT_ID --repo "$REPO" --body "$(az account show --query tenantId -o tsv)"
gh secret set AZURE_SUBSCRIPTION_ID --repo "$REPO" --body "$(az account show --query id -o tsv)"
gh variable set SWA_NAME --repo "$REPO" --body "$SWA_NAME"
gh variable set SWA_RESOURCE_GROUP --repo "$REPO" --body "$RESOURCE_GROUP"If the old AZURE_STATIC_WEB_APPS_API_TOKEN secret still exists, delete it
(gh secret delete AZURE_STATIC_WEB_APPS_API_TOKEN) — the workflow no longer
uses it, and an unused secret is needless attack surface.
A few steps need a human because they're interactive or depend on your account's privileges. Handle these and the agent can run the rest unattended.
- Signing in to Azure and GitHub.
az loginandgh auth loginopen a browser (or ask you to paste a device code) — an agent can't complete that handshake. Unblock it: run them yourself first. In a headless container, useaz login --use-device-codeandgh auth loginand complete the browser step. After that, the CLIs stay authenticated for the agent's session. - Creating the app registration.
az ad app createneeds a directory role that can register applications — Application Developer (or higher, e.g. Application Administrator), or a tenant that hasn't restricted app registration to admins. If it fails with an authorization error, either ask a Global/Application Administrator to grant you the role (or to run step 1 and hand you the resultingappId), then let the agent continue from step 2. - Creating and assigning the custom role.
az role definition createandaz role assignment createboth require Owner or User Access Administrator on the Static Web App (or its resource group / subscription). If either fails withAuthorizationFailed, ask an owner of that scope to run step 3 with yourAPP_ID, then let the agent finish step 4. - Setting GitHub secrets.
gh secret setneeds admin on the repo and aghsession with thereposcope. If it fails, rungh auth refresh -h github.com -s repo(or add the secrets manually under Settings → Secrets and variables → Actions), then re-run step 4.
Tell your agent to show each command before running it and stop on the first failure — that way an authorization error surfaces immediately with the exact step to hand off, instead of leaving a half-configured identity.
azure/login@v2 fails with "Not all values are present. Ensure 'client-id' and
'tenant-id' are supplied."
The login step received empty client-id/tenant-id values, which means one or
more of the AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID repo
secrets is missing or empty. Fix it:
-
Confirm which secrets exist — repo Settings → Secrets and variables → Actions, or run
gh secret list. You need all three. -
If the Entra identity already exists, just (re)set the secrets — you don't need to recreate the app registration:
APP_ID=$(az ad app list --display-name "russrimmerman-blog-deploy" --query "[0].appId" -o tsv) gh secret set AZURE_CLIENT_ID --body "$APP_ID" gh secret set AZURE_TENANT_ID --body "$(az account show --query tenantId -o tsv)" gh secret set AZURE_SUBSCRIPTION_ID --body "$(az account show --query id -o tsv)"
If the app registration doesn't exist yet, run the full one-time identity setup above first.
-
If
gh secret setfails with a permission error, rungh auth refresh -h github.com -s repo(needs repo admin) or add the secrets manually in the Actions secrets UI, then re-run. -
Re-run the failed run from Actions → Re-run jobs, or push a new commit to
main.
Pull requests from forks can't obtain an OIDC token, so this step will always fail for them by design. Only pushes to
mainand PRs from branches in this repo can deploy.
Custom domain (www.russrimmerman.com)
In the Static Web App → Custom domains, add www.russrimmerman.com and
create the CNAME record your DNS provider requires. For the apex
(russrimmerman.com), add it as well and set up the apex/ALIAS record or a
redirect to www. SSL certificates are provisioned automatically.
MIT © Russ Rimmerman