GHOUSE MOHAMMED

Senior Cloud Solution Technologist

Cloud Solution Architect with 15+ years of experience designing, migrating, and managing enterprise cloud environments across Azure, OCI, GCP, and AWS. Delivering secure, scalable, cost-optimized cloud & business hosting solutions across the UAE.

Azure  •  OCI  •  GCP  •  AWS

Ghouse Mohammed

About Me

The story behind the servers, the migrations, and the uptime.

Profile Summary

Result-oriented professional with 15+ years of experience across Cloud Consulting, Azure Solution Architecture, Technical Leadership, Hybrid Cloud Administration, NOC Engineering and Web/Desktop Support — with a strong foundation in business web hosting, domain & website lifecycle management, and enterprise-grade cloud infrastructure.

Extensive experience designing, migrating and hosting business-critical applications across Azure, OCI, GCP, AWS and Alibaba Cloud. Skilled in green-field cloud implementations, having migrated 500+ VMs to modern cloud data centers, automated infrastructure with Terraform, built CI/CD pipelines on GitHub, and established FinOps dashboards for cost governance.

15+Years of Experience
500+Servers & VMs Migrated
5Cloud Platforms Mastered
150+Users on Governed BI Dashboards

Experience Timeline

Senior Cloud Technologist — AW Rostamani, UAE
January 2022 – Present

Managing a cloud team overseeing 500+ servers across Azure, OCI & GCP; migrated 300+ servers to OCI/Azure; built FinOps dashboards and automated DR drills achieving 5-min RPO / 1-hour RTO.

Technical Solutions Lead — Raqmiyat LLC, UAE
January 2020 – December 2021

Led a 12-member delivery team; implemented Azure DevOps CI/CD pipelines; delivered VMware/Hyper-V to Azure migrations via Azure Migrate, Corent & ASR.

Cloud Solution Architect — GTFS, Dubai, UAE
June 2019 – December 2019

Architected Azure hosting solutions for enterprise clients; deployed AKS as PaaS; supported pre-sales (RFP/RFQ) for large hosting bids.

NOC Engineer / System Administrator — Meraas Holding, UAE
July 2014 – February 2019

Managed 400+ virtual servers across Azure, Alibaba, VMware & Hyper-V; administered AD and Office 365 for multiple tenants.

Web Developer & IT Engineer — Wipro Limited, India & UAE
Dec 2009 – Jan 2011 & June 2012 – July 2014

Managed WordPress hosting, domain connections, billing/renewal cycles, backups, and Active Directory administration.

Education

  • Bachelor of Technology Engineering, JNTU, Telangana, Hyderabad | 2005 – 2009

Certifications

  • Microsoft Azure Architect (AZ-305)
  • Microsoft Azure Administrator (AZ-104)
  • Terraform 003 Certified Professional
  • Google Cloud Platform – Core Infrastructure
  • Oracle Cloud Infrastructure Foundation
  • ManageEngine OpManager Certified Professional
  • SolarWinds Certified Professional (SCP #SCP4506)
  • Kubernetes Administrator (Trained Professional)

Projects

Signature projects & measurable impact delivered across cloud, hosting and governance initiatives.

🕸️

Hub-and-Spoke Network Redesign

Redesigned Azure's flat network architecture into a hub-and-spoke topology, deploying services across landing zones for stronger segmentation, governance & scalability.

🔗

Hybrid AD to Azure AD Migration

Migrated a hybrid Active Directory environment spanning Azure, GCP & OCI into Azure Active Directory, centralizing identity and access management.

💰

FinOps Cost Dashboard

Built a FinOps dashboard giving top management full visibility into monthly spend across GCP, Azure & OCI.

🧱

Multi-Cloud Terraform Automation

Implemented Terraform from scratch across Azure, GCP & OCI, automating infrastructure deployments.

🛡️

Multi-Cloud Web App Resilience

Built resilience for business-critical web apps on GCP by cloning & deploying them on Azure, code managed on GitHub.

🚀

CI/CD Pipeline Automation

Created CI/CD pipelines for Dev, QA & Production environments, automating new feature releases.

🚀

Automated Portfolio Website CI/CD Pipeline

Designed and implemented a fully automated CI/CD pipeline for my portfolio website using GitHub Actions, Docker, Nginx and Oracle Cloud Infrastructure (OCI). Every Git commit automatically deploys the latest version to production without manual intervention.

🔒

Azure Information Protection

Implemented Azure Information Protection and Rights Management to safeguard sensitive business data.

🖥️

Citrix Cloud Deployment

Deployed Citrix Cloud to meet security standards and vendor compliance requirements.

📈

Power BI Audit & Governance Dashboards

Built a Power BI audit dashboard for 150+ users via Microsoft REST APIs, plus a governance dashboard for disabled users.

☸️

Managed Kubernetes on GCP

Implemented and configured managed Kubernetes on GCP with multiple pods segmented across teams.

🔄

200+ VM Migration

Migrated 200+ VMs from Hyper-V & VMware to OCI and Azure as part of data center modernization.

📁

File Server → SharePoint Migration

Migrated an entire file server's data to SharePoint, modernizing collaboration and access management.

🔑

Enterprise SSO Rollout

Implemented SSO for critical applications through Azure AD and configured OAuth via service principals.

Impact at a Glance

700+Total VMs/Servers Migrated Across Roles
1 HrRecovery Time Objective (RTO) Achieved
5 MinRecovery Point Objective (RPO) Achieved
150+Users Managed via Power BI Governance

Case Studies

In-depth looks at five core disciplines: migration, disaster recovery, landing zones, automation, and DevOps CI/CD.

Cloud Migration

Zero-disruption migration of 500+ servers and VMs across on-premises and multi-cloud platforms.

Challenge

Legacy VMware and Hyper-V estates needed to move onto modern cloud infrastructure (Azure, OCI, GCP) without downtime for business-critical banking and enterprise applications.

Approach

Used Azure Migrate, Corent, and Azure Site Recovery (ASR) to assess, replicate, and cut over workloads in phases. Migrated 300+ servers to OCI/Azure at AW Rostamani, plus 200+ VMs from Hyper-V/VMware to OCI & Azure in a separate modernization effort.

Impact

500+ servers and VMs successfully migrated with minimal business disruption, forming the foundation for ongoing multi-cloud operations and cost optimization.

Tools & Platforms Used

Azure MigrateCorentAzure Site Recovery VMwareHyper-VOCIAzure

Enterprise DR

Building resilient disaster recovery architecture for mission-critical business applications.

Challenge

Business-critical applications needed a proven, testable disaster recovery plan with minimal data loss and fast recovery in the event of an outage.

Approach

Implemented Azure and OCI backup policies with automated patching, then ran full-scale DR drills yearly to validate recovery procedures end-to-end for the most critical workloads.

Impact

Achieved a 5-minute Recovery Point Objective (RPO) and 1-hour Recovery Time Objective (RTO) — giving the business confidence in continuity planning.

DR Metrics

5 MinRecovery Point Objective (RPO)
1 HrRecovery Time Objective (RTO)
YearlyFull DR Drill Cadence

Landing Zone

Re-architecting a flat Azure network into a governed, scalable landing zone topology.

Challenge

A flat Azure network architecture limited segmentation, governance, and the ability to scale services safely across business units.

Approach

Redesigned the network into a hub-and-spoke topology, deploying workloads across purpose-built landing zones with clear governance boundaries between environments.

Impact

Stronger network segmentation, simplified governance, and a scalable foundation for onboarding new workloads and business units going forward.

Key Concepts Applied

Hub-and-Spoke TopologyLanding Zones Network SegmentationGovernance

Cloud Automation

Eliminating manual provisioning through multi-cloud Infrastructure-as-Code and CI/CD.

Challenge

Manual, inconsistent infrastructure provisioning across Azure, GCP & OCI slowed delivery and introduced configuration drift.

Approach

Implemented Terraform from scratch across all three clouds, then built CI/CD pipelines for Dev, QA & Production environments using GitHub Actions and Azure DevOps to automate releases.

Impact

Consistent, repeatable infrastructure deployments across clouds, faster release cycles, and reduced manual operational overhead.

Tools & Platforms Used

TerraformGitHub ActionsAzure DevOps AnsibleDocker

Automated Portfolio Website CI/CD Pipeline

Designing a production-ready CI/CD pipeline using GitHub Actions, Docker, Linux and Oracle Cloud Infrastructure.

Challenge

Website updates were previously manual and required direct access to the production server. This increased deployment time, introduced potential human error, and created operational overhead whenever content changes were required.

Approach

Implemented a fully automated CI/CD solution using GitHub Actions. Every change committed to the GitHub repository automatically triggers a deployment workflow which securely connects to an Oracle Cloud Infrastructure Ubuntu VM, updates source code, builds Docker containers and restarts the website.

Impact

Reduced deployment time from manual operations to automated deployments, improved reliability, eliminated repetitive server administration tasks and established a modern DevOps workflow for continuous website delivery.

Solution Architecture

Developer Workstation
        │
        ▼
   Git Commit
        │
        ▼
 GitHub Repository
        │
        ▼
 GitHub Actions
        │
        ▼
 Secure SSH Connection
        │
        ▼
 Oracle Cloud VM
        │
        ▼
 Git Pull Latest Code
        │
        ▼
 Docker Build
        │
        ▼
 Docker Container Restart
        │
        ▼
 Production Website

Technologies Used

GitHub Actions Git Docker Docker Compose OCI Ubuntu Linux Nginx SSH DevOps CI/CD

Key Achievements

  • Implemented fully automated production deployments.
  • Integrated GitHub Actions with Oracle Cloud Infrastructure.
  • Containerized website deployment using Docker and Docker Compose.
  • Established secure SSH-based deployment automation.
  • Eliminated manual deployment activities.
  • Enabled rapid website iteration through Git-based workflows.
  • Demonstrated modern DevOps and Infrastructure Automation practices.

Project Highlights

100% Automated Deployments
0 Manual Production Updates
1 Git Push to Production Workflow
OCI Cloud Platform Hosted

Architecture Lab

Architecture patterns designed and implemented across enterprise cloud environments.

🕸️

Hub-and-Spoke Network

Centralized hub for shared services (firewall, connectivity) with spoke networks per workload, improving segmentation and governance.

🔀

Multi-Cloud Resilience

Business-critical apps cloned and deployed across GCP and Azure simultaneously for failover resilience, with source code managed centrally on GitHub.

🪪

Hybrid Identity Architecture

Consolidated hybrid Active Directory across Azure, GCP & OCI into a single Azure AD identity plane with SSO and OAuth via service principals.

🛟

DR & Backup Architecture

Automated backup policies across Azure & OCI, validated through yearly DR drills achieving 5-min RPO / 1-hour RTO.

☸️

Containerized Workload Architecture

AKS and GKE-based container platforms segmented by team/workload, supporting multi-tenant Kubernetes operations.

🧱

Infrastructure-as-Code Foundation

Terraform-driven infrastructure across three clouds, integrated into CI/CD pipelines for consistent, auditable deployments.

Cloud

Multi-cloud expertise across the four platforms I design, migrate, and operate on daily.

Azure

Certified Azure Architect & Administrator with deep enterprise operational experience.

🏛️

Architecture & Networking

  • Hub-and-spoke network redesign
  • Azure Firewall & traffic routing
  • Site-to-site VPNs & peering
☸️

Compute & Containers

  • Azure Kubernetes Service (AKS)
  • Azure Web Apps
  • Azure VDI
🔐

Identity & Security

  • Azure AD & hybrid identity migration
  • SAML SSO configurations
  • Azure Information Protection
🔁

Migration & DevOps

  • Azure Migrate, Corent, ASR
  • Azure DevOps CI/CD pipelines
  • Azure Update Manager & patching

Certifications

AZ-305 ArchitectAZ-104 Administrator

AWS

Multi-cloud pricing and architecture principles applied alongside Azure, GCP & OCI.

Multi-Cloud Context

While my deepest hands-on operational experience is concentrated in Azure, OCI, and GCP, I bring strong pricing expertise across AWS and apply the same IaaS/PaaS architecture principles, cost governance discipline (FinOps), and Terraform-based Infrastructure-as-Code practices when evaluating or integrating AWS into a broader multi-cloud strategy.

💰

Pricing & Cost Governance

  • AWS pricing calculator expertise
  • FinOps-style cost dashboards
  • Multi-cloud budget forecasting
🧱

Architecture Principles

  • IaaS & PaaS design patterns
  • Hybrid & multi-cloud resilience
  • Terraform-driven provisioning concepts

GCP

GCP Core Infrastructure certified, with hands-on delivery across compute, data & Kubernetes.

🚀

Compute & Deployment

  • App Engine & Cloud Run
  • Business-critical app hosting
  • Multi-cloud resilience with Azure
📊

Data & Analytics

  • BigQuery data pipelines
  • Power BI integration
  • FinOps cost dashboards
☸️

Kubernetes (GKE)

  • Managed Kubernetes on GCP
  • Multiple pods segmented by team
  • Container-based workloads

Certification

GCP – Core Infrastructure

OCI

Oracle Cloud Infrastructure Foundation certified, with migration and operations experience.

🔄

Migration

  • VMware/Hyper-V to OCI migrations
  • 300+ servers migrated to OCI/Azure
  • Green-field OCI implementations
🛟

Resilience & Backup

  • OCI backup policies
  • DR drills (5-min RPO / 1-hour RTO)
  • Automated patching
🌐

Networking

  • OCI cloud networking
  • Multi-cloud connectivity (OCI ↔ Azure ↔ GCP)

Certification

OCI Foundation

Automation

Automating infrastructure, deployments, and operational workflows across the stack.

🚀

CI/CD

  • GitHub Actions pipelines
  • Azure DevOps CI/CD
  • Dev / QA / Production release automation
🧰

Config & Containers

  • Ansible
  • Docker & Containers
⌨️

Scripting

  • PowerShell & Bash scripting
  • Automating file movements (FTP/SFTP/SMTP)
🩹

Patch & Update Automation

  • SCCM & Ivanti patch management
  • Azure Update Manager
  • Automated backup policies

Terraform

Terraform 003 Certified Professional — multi-cloud Infrastructure-as-Code from the ground up.

Multi-Cloud IaC

Implemented Terraform from scratch across Azure, GCP & OCI, automating infrastructure deployments and eliminating manual provisioning.

Resource Management

Managing VMs, containers, and other cloud resources as code across all three platforms with consistent, auditable configurations.

CI/CD Integration

Terraform workflows integrated into GitHub Actions and Azure DevOps pipelines for automated, repeatable deployments across environments.

Certification

Terraform 003 Certified Professional

Kubernetes

Trained Kubernetes Administrator with hands-on multi-cloud container platform experience.

Azure Kubernetes Service (AKS)

Deployed AKS as a PaaS offering for enterprise clients, supporting containerized business applications.

Google Kubernetes Engine (GKE)

Implemented and configured managed Kubernetes on GCP with multiple pods segmented across different teams.

Containers & Docker

Working knowledge of Docker and container-based application packaging as the foundation for Kubernetes workloads.

Certification

Kubernetes Administrator (Trained Professional)

AI

Exploring how AI and Copilot tools accelerate cloud operations, automation, and analytics.

AI-Enabled Cloud Operations

As a cloud architect, I'm actively exploring how AI tools like Microsoft 365 Copilot can be woven into day-to-day operations — from accelerating documentation and runbooks, to surfacing insights inside FinOps and Power BI cost dashboards, to speeding up troubleshooting and infrastructure-as-code authoring. This is an evolving part of my toolkit as I continue to bring AI-assisted workflows into enterprise cloud environments.

📝

Documentation & Runbooks

  • AI-assisted LLD/HLD drafting
  • Faster incident runbook creation
📊

Analytics & FinOps

  • AI-augmented Power BI insights
  • Cost anomaly awareness in dashboards
⚙️

Automation & IaC

  • AI-assisted Terraform/script authoring
  • Faster troubleshooting workflows

Blog

Real-world notes on cloud migration, Terraform, DR, multi-cloud architecture and developer productivity.

GitHub Hidden Gems

Productivity hacks every developer and cloud engineer should know

By Ghouse Mohammed - Cloud Solution Architect - August 23, 2026 - 5 min read

Most professionals use GitHub every day for source control, code review, and collaboration, but few know about the hidden URL tricks and shortcuts that can save hours of exploration time. Below are three practical, real-world tricks I tested directly on one of my own repositories, complete with live screenshots, followed by a set of bonus tips every engineer should keep in their back pocket.

Repository used for all screenshots below:
github dot com slash ghousemohammeditpro slash HelloWorldDockerImages-DifferentRuntimes

1. Reverse-Engineer Any Repo into an AI Prompt (github to gitreverse)

Replace "github" with "gitreverse" in any repository URL to instantly generate a natural-language description of what the project does, perfect for feeding into ChatGPT, Copilot, or Claude.

github dot com slash ghousemohammeditpro slash HelloWorldDockerImages-DifferentRuntimes
(down arrow)
www dot gitreverse dot com slash ghousemohammeditpro slash HelloWorldDockerImages-DifferentRuntimes
Original GitHub repository
Screenshot 1 - Original repository on GitHub
GitReverse generated prompt
Screenshot 2 - GitReverse instantly reverse-engineers the repo into a usable AI prompt

Why it matters: great for quickly understanding unfamiliar codebases, documenting legacy projects, or generating onboarding prompts for new team members.

2. Visualize Repo Structure Instantly (git to uit)

Replace "git" with "uit" anywhere in a GitHub URL to get a clean, token-counted tree view of every folder and file, no cloning required.

github dot com slash ... -> uithub dot com slash ...
uithub folder structure view
Screenshot 3 - uithub.com renders the full folder/file hierarchy with token counts

Why it matters: ideal for architects and reviewers who need to understand a project's layout before diving into the code, and handy for copying a whole repo's content into an LLM context window.

3. Open Any Repo Directly in VS Code (github to github1s)

Add "1s" right after "github" in the URL to open the repository inside a full browser-based VS Code editor, complete with file explorer, syntax highlighting, and search.

github dot com slash ... -> github1s dot com slash ...
github1s VS Code view
Screenshot 4 - github1s.com opens the repo in a familiar VS Code interface, viewing pom.xml

Why it matters: browse, search, and review code with a real editor experience, without cloning anything locally.

Bonus Tips

  • Press the "." (period) key on any repo page to instantly launch github.dev, a full VS Code editor, no URL editing needed.
  • Press "t" inside a repo to open the fuzzy file finder and jump straight to any file by name.
  • Press "?" anywhere on GitHub to reveal the full keyboard shortcut cheat-sheet.
  • Append /archive/refs/heads/main.zip to a repo URL to download the entire codebase as a ZIP without opening the UI.
  • Click "Blame" on any file to see exactly who changed each line and when, invaluable for root-cause analysis.
  • Check the "Security" tab for dependency vulnerabilities, secret scanning alerts, and the dependency graph, often overlooked but critical for DevSecOps.

Final Thoughts

GitHub is far more than a source-control platform. With a few URL tweaks and hidden shortcuts, it becomes a powerful productivity, documentation, and AI-enablement tool. Try these on your own repositories and see how much faster you can explore, review, and understand code.

What is your favorite hidden GitHub trick? Connect with me on LinkedIn and let me know.

#GitHub#DeveloperProductivity#CloudComputing #DevOps#Azure#OCI #GCP#CloudArchitecture#TechTips

Understanding the Microsoft Copilot Ecosystem

Which Copilot should you use? A breakdown of all seven Microsoft Copilot offerings.

By Ghouse Mohammed - Cloud Solution Architect - August 28, 2026 - 6 min read

Artificial Intelligence is no longer a futuristic concept. Microsoft has integrated AI across its entire technology stack through a family of products collectively known as Microsoft Copilot. However, many organizations and users are often confused about the different Copilot offerings and where each one fits.

In simple terms, not all Copilots are the same. Some are designed to boost personal productivity, others help developers write code, some enhance security operations, while others allow organizations to build their own AI agents and automation workflows. Microsoft itself distinguishes between offerings such as Copilot Chat, Microsoft 365 Copilot, GitHub Copilot, Security Copilot, Copilot Studio, and Power Platform Copilot.

1. Copilot Chat (Free Enterprise AI Chat)

What is it? Copilot Chat is Microsoft's secure AI assistant that provides web-grounded conversational AI for work users. It is included with most Microsoft 365 subscriptions and offers enterprise data protection.

Best for: Everyday AI assistance, content generation, research and brainstorming, quick summaries.

  • ✓ Summarize a lengthy PDF or document
  • ✓ Draft professional emails and responses
  • ✓ Generate blog ideas, meeting agendas, or project plans

2. Microsoft 365 Copilot (The Productivity Copilot)

What is it? Microsoft 365 Copilot integrates directly with Word, Excel, PowerPoint, Outlook, Teams, OneNote, and SharePoint, utilizing organizational data such as emails, files, meetings, and calendars to assist users in their daily work.

Best for: Knowledge workers, business users, managers and executives, project teams.

  • ✓ Generate a PowerPoint presentation from a Word document
  • ✓ Summarize Teams meetings and extract action items
  • ✓ Analyze Excel data and create reports instantly

Example prompt:

"Create a quarterly business update based on the sales spreadsheet and presentation from last month."

3. GitHub Copilot (Developer Copilot)

What is it? GitHub Copilot is an AI-powered coding assistant that helps developers write, debug, explain, and optimize code directly inside development tools such as VS Code and GitHub.

Best for: Software developers, DevOps engineers, cloud engineers, automation specialists.

  • ✓ Generate PowerShell, Python, Terraform, or Bash scripts
  • ✓ Explain unfamiliar code structures
  • ✓ Debug application issues and suggest fixes

Example prompt:

"Generate Terraform code for an Azure Virtual Network with three subnets."

4. Security Copilot (Cybersecurity Copilot)

What is it? Microsoft Security Copilot is built specifically for security teams. It combines AI with Microsoft security products such as Defender, Sentinel, Intune, and Entra ID to accelerate threat detection and investigation.

Best for: SOC analysts, security engineers, incident responders, threat hunters.

  • ✓ Analyze suspicious logs and alerts
  • ✓ Investigate ransomware incidents
  • ✓ Generate incident response reports

Example prompt:

"Summarize all suspicious lateral movement attempts detected today."

5. Microsoft Copilot Studio (Build Your Own Copilot)

What is it? Copilot Studio enables organizations to create custom AI agents and connect them to business applications, databases, SharePoint sites, APIs, and third-party systems — extending Copilot beyond standard Microsoft 365 capabilities.

Best for: Business automation, IT departments, HR teams, customer service teams.

  • ✓ Build an HR assistant that answers policy questions
  • ✓ Create an IT support bot connected to internal documentation
  • ✓ Connect AI agents to Salesforce, Oracle, ServiceNow, Jira, or SAP

An IT team can create an internal helpdesk agent that automatically answers password reset, VPN, and onboarding questions.

6. Power Platform Copilot

What is it? Power Platform Copilot brings AI capabilities into Power Apps, Power Automate, Power BI, and Power Pages, allowing users to build applications and automations using natural language.

Best for: Citizen developers, business analysts, process automation teams.

  • ✓ Create Power Apps using natural language
  • ✓ Generate Power BI reports and insights
  • ✓ Build automation workflows without coding

Example prompt:

"Create a leave request application with manager approval."

7. Copilot for Power BI

What is it? A specialized Copilot focused on analytics and business intelligence, helping users query datasets, generate visualizations, and uncover insights.

Best for: Finance teams, business analysts, data professionals.

  • ✓ Build executive dashboards
  • ✓ Identify trends in sales data
  • ✓ Generate narrative summaries from reports

Example prompt:

"Why did revenue decline in Q2 compared to Q1?"

Choosing the Right Copilot

NeedRecommended Copilot
General AI Chat (Free of Cost)Copilot Chat
Microsoft 365 ProductivityMicrosoft 365 Copilot
Software DevelopmentGitHub Copilot
CybersecuritySecurity Copilot
Custom AI AgentsCopilot Studio
Business AutomationPower Platform Copilot
Analytics & ReportingPower BI Copilot

Final Thoughts

Microsoft's Copilot vision is much bigger than a chatbot. Think of it as an AI ecosystem, where each Copilot serves a different audience:

  • Copilot Chat → AI assistant for everyone
  • Microsoft 365 Copilot → Productivity companion
  • GitHub Copilot → Developer partner
  • Security Copilot → SOC analyst assistant
  • Copilot Studio → AI agent builder
  • Power Platform Copilot → Automation enabler
  • Power BI Copilot → Data analyst companion

The most successful organizations are not choosing just one Copilot. They are combining multiple Copilot experiences to empower employees, automate processes, improve security, and unlock insights from their data. The result is a workplace where AI becomes a true digital teammate rather than just another application.

Which Copilot are you most excited to try? Connect with me on LinkedIn and let me know.

#MicrosoftCopilot#AI#Microsoft365 #GitHubCopilot#CopilotStudio#CloudComputing

Building a Zero-Touch CI/CD Pipeline for My Portfolio Website

From git push to production in under 15 seconds — GitHub Actions, Docker, Nginx and Oracle Cloud Infrastructure working together.

By Ghouse Mohammed - Cloud Solution Architect - September 6, 2026 · 12:21 PM GST - 9 min read

As a cloud architect, I talk about automation and DevOps practices for a living — designing CI/CD pipelines, Infrastructure-as-Code and container platforms for enterprise workloads. So it felt overdue to turn that same discipline inward and fully automate the deployment of my own portfolio website, meetghouse.com. This article walks through exactly how I built a production-grade CI/CD pipeline for a personal project, including every real error message I hit and how I resolved each one, so you can replicate it for your own site or lab environment.

The Problem: Manual Deployments Don't Scale

Before this project, every content update to my website followed the same manual routine: SSH into the Oracle Cloud Infrastructure (OCI) Ubuntu VM, pull the latest code, rebuild the Docker image, and restart the container by hand. It worked, but it was slow, repetitive, and left room for human error — exactly the kind of problem automation exists to solve.

The Goal

  • Push code to GitHub and have it deployed automatically — zero manual server access.
  • Keep the website containerized with Docker for consistency and portability.
  • Use secure, secret-based authentication rather than hardcoded credentials.
  • Make the whole pipeline visible and auditable directly inside GitHub.

Solution Architecture

Developer Workstation (Windows)
        │
        ▼
   git add / commit / push
        │
        ▼
 GitHub Repository (main branch)
        │
        ▼
 GitHub Actions Workflow Triggered
        │
        ▼
 Secure SSH Connection (appleboy/ssh-action)
        │
        ▼
 Oracle Cloud Infrastructure — Ubuntu VM
        │
        ▼
 git pull origin main
        │
        ▼
 docker compose build
        │
        ▼
 docker compose up -d
        │
        ▼
 Nginx Container Serving Updated Site

Step 1: Writing the GitHub Actions Workflow

Everything starts with a single YAML file committed to the repository at .github/workflows/deploy.yml. This tells GitHub: "whenever code lands on main, connect to my server and deploy it."

name: Deploy Portfolio

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Deploy to Ubuntu VM
        uses: appleboy/ssh-action@v1.2.2
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            cd /opt/portfolio
            ./deploy.sh

The three secrets — DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_KEY — are stored under Repository → Settings → Secrets and variables → Actions, so no credential ever touches the codebase.

Step 2: The Deployment Script on the VM

On the Ubuntu VM, a small shell script does the actual heavy lifting: pulling code, rebuilding the container and cleaning up old images.

#!/bin/bash
set -e

echo "Starting deployment..."
cd /opt/portfolio

echo "Pulling latest code..."
git pull origin main

echo "Building Docker image..."
docker compose build

echo "Restarting portfolio..."
docker compose up -d

echo "Removing unused Docker images..."
docker image prune -f

echo "Deployment completed successfully!"
docker ps

Step 3: The Errors I Actually Hit (and How I Fixed Them)

No automation project goes smoothly on the first try, and this one was no exception. Here are the three real issues I ran into, in the order I hit them:

Issue 1 — GitHub Actions tab showed "Get started with GitHub Actions" instead of my workflow. The workflow file existed locally but had only been git add-ed, never committed or pushed. The fix was simply completing the commit and push:

git commit -m "Add GitHub Actions deployment workflow"
git push origin main

Along the way, the very first commit attempt also failed with Author identity unknown, which is Git's way of saying it has no name/email configured on that machine yet:

git config --global user.name "Ghouse Mohammed"
git config --global user.email "your-github-email@example.com"

Issue 2 — SSH key was passphrase protected. Once the workflow started running, the first real failure was:

ssh.ParsePrivateKey: ssh: this private key is passphrase protected
ssh: handshake failed: ssh: unable to authenticate

GitHub Actions runs unattended, so it can't be prompted for a passphrase. The fix was generating a dedicated, passphrase-free deploy key directly on the VM and registering the public half in authorized_keys:

ssh-keygen -t ed25519 -f ~/.ssh/github_deploy -N ""
cat ~/.ssh/github_deploy.pub >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

The private key content was then pasted into the DEPLOY_SSH_KEY GitHub secret.

Issue 3 — "Permission denied (publickey)" on the git pull itself. After SSH into the VM started working, the deployment log showed:

git@github.com: Permission denied (publickey).
fatal: Could not read from remote repository.

This one was subtle: the VM's git remote was configured to use the SSH protocol (git@github.com:...), which needs its own separate SSH key trust with GitHub — completely different from the deploy key used to log into the VM. Since the repository is public, the cleanest fix was switching the remote to HTTPS, which needs no key at all:

cd /opt/portfolio
git remote set-url origin https://github.com/ghousemohammeditpro/portfolio.git
git remote -v

After that change, both a manual git pull origin main and the automated GitHub Actions run completed without any authentication errors.

Step 4: Proving the Pipeline Works End-to-End

The best way to validate a CI/CD pipeline isn't reading logs — it's watching a real, visible change travel through it. I made a small edit to index.html directly on my Windows machine (never on the server), committed and pushed it:

git add .
git commit -m "CI/CD title test"
git push origin main

Within seconds, GitHub Actions triggered automatically, connected to the OCI VM over SSH, pulled the new commit, rebuilt the Docker image and restarted the container — and the change appeared live on the website after a hard refresh. That single test confirmed every link in the chain was working:

  • ✓ Git push triggers GitHub Actions automatically
  • ✓ Secure SSH connection to the OCI VM succeeds
  • ✓ deploy.sh executes without manual intervention
  • ✓ Docker image rebuilds with the latest code
  • ✓ Container restarts and serves the update instantly
GitHub Actions workflow run history for the portfolio repository showing five consecutive successful Deploy Portfolio runs
Screenshot - The actual GitHub Actions run history from github.com/ghousemohammeditpro/portfolio/actions, captured the same day this pipeline was built: five consecutive green "Deploy Portfolio" runs, starting with the initial "Add GitHub Actions deployment workflow" commit at 12:30 PM and ending with a live test push at 3:41 PM — real timestamps, real commits, real deployments.

This isn't a mocked-up diagram — it's the actual run history from my repository the day I built this. Every commit hash, timestamp, and run duration in that screenshot corresponds to a real git push from my Windows machine that triggered a real SSH deployment to my OCI VM, which is the whole point of writing this up: nothing here is theoretical.

Technologies Used

GitHub ActionsGitDocker Docker ComposeOracle Cloud Infrastructure Ubuntu LinuxNginxSSH DevOpsCI/CD

Lessons Learned

  • Always verify git status shows a clean commit before assuming a push has synced a new file to GitHub — "added" is not the same as "committed."
  • Automation tools like GitHub Actions can never handle interactive prompts — SSH keys used in pipelines must always be generated without a passphrase.
  • A server can have git working perfectly in an interactive shell and still fail inside an automated script if the remote protocol (SSH vs HTTPS) doesn't match the credentials actually available to that process.
  • Test with a small, visible change (like a title tag) rather than a large content update — it isolates pipeline issues from content issues.
  • Keep deployment scripts idempotent and self-cleaning (docker image prune -f) so repeated runs don't slowly fill up disk space.

What's Next

With the core pipeline live, the next phase is hardening it further: automated SSL renewal, a lightweight health-check step after deployment, GitHub Environments for staged rollouts, and Teams/email notifications on deployment success or failure. I'll cover each of these in future posts as I build them out.

Final Thoughts

This project was a great reminder that the best way to master DevOps practices isn't just applying them at work — it's living them on your own infrastructure, mistakes and all. Every error message above was a genuine part of the build process, not a hypothetical, and each one taught me something I'll now apply faster the next time I stand up a pipeline for a client or internal team.

Have you automated your own website or lab deployment? Connect with me on LinkedIn and share what tripped you up.

#CI/CD#GitHubActions#Docker #DevOps#OracleCloud#OCI #CloudArchitecture#Automation#Nginx

Automate Your Daily Health Check Through Copilot

A complete, follow-along guide to building a self-running, self-reporting infrastructure health check with PowerShell and Microsoft 365 Copilot.

By Ghouse Mohammed - Cloud Solution Architect - October 8, 2026 - Practical implementation guide

A complete, follow-along guide to building a self-running, self-reporting infrastructure health check – using Microsoft 365 Copilot as your build partner, not just a chatbot.

If your team runs a daily routine where someone logs into a handful of servers, checks a list of systems by hand, and emails the results – this guide walks through replacing that entire process with a scheduled, self-reporting PowerShell automation. Every script in this article is complete and generic: copy it, drop in your own server names and credentials, and it will run in your environment exactly as described.

This isn't a theoretical overview. It's the actual build, written the way it really happened – one conversation with Copilot at a time, including the mistakes, the debugging, and the fixes. By the end, you'll have a working daily health check that queries your real systems, emails a report automatically, and flags problems the moment they appear.

What you'll build

A small PowerShell framework with four moving parts:

  1. A shared module with common building blocks – result objects, logging, authentication helpers, and the HTML report generator.
  2. One independent script per health check – each one queries a real system and returns a simple OK / WARNING / FAILED status.
  3. An orchestrator that loads every check, runs them safely, builds the report, and emails it.
  4. A Windows Scheduled Task that runs the orchestrator automatically, every morning, with no one watching.
The complete flow: a scheduled task triggers the orchestrator, which runs every check independently and emails the resulting report.
The complete flow: a scheduled task triggers the orchestrator, which runs every check independently and emails the resulting report.

Before You Start

A short checklist to have ready. None of this needs to be perfect on day one – you can add checks and credentials incrementally, exactly as this guide does.

Requirement

Why it matters

PowerShell 7.2+

Required on the machine that will run the automation. Windows PowerShell 5.1 is not enough – several cmdlets used here are 7.x only.

A dedicated service account

A plain domain user (e.g. svc-healthcheck) with no admin rights of its own. This is who the scheduled task runs as – never your own personal account.

Local admin rights (one-time)

Needed only to install PowerShell modules and register the scheduled task. The service account itself stays low-privilege.

An Azure app registration (optional)

Only needed if any of your checks query Azure or Microsoft Graph (e.g. cloud backup status, sending email). Covered in Step 6.

Network access

The automation server needs to reach whatever it's checking – domain controllers, file servers, Azure, your SMTP relay, etc.

Step 1 – Set Up the Folder Structure

Everything lives under one folder on the automation server. Create this structure first:

HealthCheck\
  Invoke-DailyHealthCheck.ps1     <- the orchestrator (you run this)
  Register-HealthCheckTask.ps1    <- one-time setup script for the schedule
  config\
    HealthCheck.config.json       <- all settings: servers, thresholds, recipients
  modules\
    HealthCheck.Common.psm1       <- shared helpers used by every check
  checks\
    01-Test-ADReplication.ps1     <- one file per check (numbered for clarity)
    02-Test-SmtpServer.ps1
    ...
  Reports\                       <- created automatically, holds daily evidence

The numbering on the check filenames is just a convention to keep them readable in a file listing – the orchestrator loads every .ps1 file in the checks folder regardless of name, so feel free to name yours however makes sense for your environment.

One habit worth adopting early: avoid leaving old or backup copies of a check script in this folder. The orchestrator loads every .ps1 file it finds, so two files defining the same function will silently compete – whichever loads last wins, with no warning. This bit us once during the real build, and it's worth avoiding.

Step 2 – The Shared Module

Every check and the orchestrator itself leans on one shared module. It has no checks of its own – it's pure infrastructure: how to build a result, how to log, how to authenticate, and how to turn results into an HTML report and send it by email.

Save this as modules\HealthCheck.Common.psm1:

<#
 Shared helpers for the daily health check automation.
 Result objects, logging, authentication, HTML report, and mail delivery.
#>
 
$script:HCSeverity = @{ OK = 0; MANUAL = 1; WARNING = 2; ERROR = 3; FAILED = 4 }
$script:HCLogFile  = $null
 
function New-HCResult {
    param(
        [int]$Id, [string]$Name,
        [ValidateSet('OK','WARNING','FAILED','ERROR','MANUAL')]
        [string]$Status = 'OK',
        [string]$Comments = '',
        [object[]]$Details = @()
    )
    [pscustomobject]@{
        Id = $Id; Name = $Name; Status = $Status; Comments = $Comments
        Details = @($Details); CheckedAt = Get-Date; DurationSec = $null
    }
}
 
function New-HCDetail {
    param([string]$Target, [string]$Check, [string]$Value,
        [ValidateSet('OK','WARNING','FAILED','ERROR','INFO')] [string]$State = 'OK')
    [pscustomobject]@{ Target = $Target; Check = $Check; Value = $Value; State = $State }
}
 
function Get-HCWorstStatus {
    param([string[]]$Status)
    $s = @($Status | Where-Object { $_ -and $script:HCSeverity.ContainsKey($_) })
    if (-not $s) { return 'OK' }
    $s | Sort-Object { $script:HCSeverity[$_] } -Descending | Select-Object -First 1
}
 
function Get-HCSummaryComment {
    param([object[]]$Details, [string]$OkMessage, [int]$Max = 5)
    $bad = @($Details | Where-Object { $_.State -in 'WARNING','FAILED','ERROR' })
    if (-not $bad) { return $OkMessage }
    $text = $bad | Select-Object -First $Max | ForEach-Object { "$($_.Target): $($_.Check) = $($_.Value)" }
    $suffix = if ($bad.Count -gt $Max) { " (+$($bad.Count - $Max) more)" } else { '' }
    ($text -join '; ') + $suffix
}

Part 1 of 3 – result objects. Every check returns one New-HCResult, built from one or more New-HCDetail rows.

function Start-HCLog { param([string]$Path) $script:HCLogFile = $Path; New-Item -ItemType File -Path $Path -Force | Out-Null }
 
function Write-HCLog {
    param([string]$Message, [string]$Level = 'INFO')
    $line = '{0:yyyy-MM-dd HH:mm:ss} [{1,-7}] {2}' -f (Get-Date), $Level, $Message
    Write-Host $line
    if ($script:HCLogFile) { Add-Content -Path $script:HCLogFile -Value $line }
}
 
function Invoke-HCCheck {
    # Runs one check safely - a crashing check becomes an ERROR row, not a stopped run
    param([int]$Id, [string]$Name, [scriptblock]$Action)
    $sw = [Diagnostics.Stopwatch]::StartNew()
    Write-HCLog "[$Id] $Name - started"
    try {
        $r = & $Action
        if (-not $r) { $r = New-HCResult -Status ERROR -Comments 'Check returned no result' }
    }
    catch { $r = New-HCResult -Status ERROR -Comments "Check could not run: $($_.Exception.Message)" }
    $r.Id = $Id; $r.Name = $Name; $r.DurationSec = [math]::Round($sw.Elapsed.TotalSeconds, 1)
    Write-HCLog "[$Id] $Name - $($r.Status) - $($r.Comments)" -Level $r.Status
    $r
}
 
function Connect-HCAzure {
    param($Auth)
    Import-Module Az.Accounts -ErrorAction Stop
    Connect-AzAccount -ServicePrincipal -TenantId $Auth.TenantId -ApplicationId $Auth.ClientId `
        -CertificateThumbprint $Auth.CertificateThumbprint -WarningAction SilentlyContinue | Out-Null
}
 
function Connect-HCGraph {
    param($Auth)
    Import-Module Microsoft.Graph.Authentication -ErrorAction Stop
    Connect-MgGraph -TenantId $Auth.TenantId -ClientId $Auth.ClientId `
        -CertificateThumbprint $Auth.CertificateThumbprint -NoWelcome
}

Part 2 of 3 – logging and authentication. Connect-HCAzure and Connect-HCGraph both sign in with a certificate, never a password.

function ConvertTo-HCHtmlReport {
    param([object[]]$Results, [datetime]$RunDate)
    $color = @{ OK='#107C10'; WARNING='#C19C00'; FAILED='#D13438'; ERROR='#A4262C'; MANUAL='#605E5C' }
    $overall = Get-HCWorstStatus (($Results | Where-Object Status -ne 'MANUAL').Status)
    $rows = foreach ($r in ($Results | Sort-Object Id)) {
        $c = $color[$r.Status]
        "<tr><td>$($r.Id)</td><td>$($r.Name)</td>" +
        "<td style='color:#fff;background:$c;font-weight:bold'>$($r.Status)</td>" +
        "<td>$($r.Comments)</td></tr>"
    }
@"
<html><body>
<p>Overall status: <b style='color:$($color[$overall])'>$overall</b></p>
<table border='1'><tr><th>S.No.</th><th>Service</th><th>Status</th><th>Comments</th></tr>
$($rows -join "`n")
</table></body></html>
"@
}
 
function Send-HCReport {
    param($MailConfig, [string]$Subject, [string]$Html)
    $body = @{
        message = @{
            subject = $Subject
            body = @{ contentType = 'HTML'; content = $Html }
            toRecipients = @($MailConfig.To | ForEach-Object { @{ emailAddress = @{ address = $_ } } })
        }
        saveToSentItems = $true
    } | ConvertTo-Json -Depth 10
    Invoke-MgGraphRequest -Method POST `
        -Uri "https://graph.microsoft.com/v1.0/users/$($MailConfig.From)/sendMail" `
        -Body $body -ContentType 'application/json' | Out-Null
}
 
Export-ModuleMember -Function *-HC*

Part 3 of 3 – the HTML report and the Graph-based mail sender (simplified here; the full version also supports a plain SMTP relay).

Step 3 – Your First Check: Directory Replication

Start with something that needs no cloud credentials at all – just a domain account with normal read access. This check confirms every domain controller is replicating successfully with its partners.

Save this as checks\01-Test-ADReplication.ps1:

<#
 Check: Active Directory replication health
 Needs: RSAT ActiveDirectory module. Domain Users read access is enough.
#>
function Test-HCADReplication {
    param($Settings)
    Import-Module ActiveDirectory -ErrorAction Stop
    $domain = if ($Settings.Domain) { $Settings.Domain } else { (Get-ADDomain).DNSRoot }
    $maxAge = if ($Settings.MaxReplicationAgeMinutes) { [int]$Settings.MaxReplicationAgeMinutes } else { 180 }
 
    $dcs = @(Get-ADDomainController -Filter * -Server $domain)
    $details = foreach ($dc in $dcs) {
        try {
            $links = Get-ADReplicationPartnerMetadata -Target $dc.HostName -Scope Server -PartnerType Inbound -ErrorAction Stop
            foreach ($l in $links) {
                $ageMin = if ($l.LastReplicationSuccess) { [int]((Get-Date) - $l.LastReplicationSuccess).TotalMinutes } else { $null }
                $state = if ($l.LastReplicationResult -ne 0) { 'FAILED' }
                         elseif ($null -eq $ageMin -or $ageMin -gt $maxAge) { 'WARNING' }
                         else { 'OK' }
                New-HCDetail -Target $dc.HostName -Check "Replication from $(($l.Partner -split ',')[1])" `
                    -Value "Result $($l.LastReplicationResult), $ageMin min ago" -State $state
            }
        }
        catch {
            New-HCDetail -Target $dc.HostName -Check 'Reachability' -Value $_.Exception.Message -State FAILED
        }
    }
 
    $details = @($details)
    $comment = Get-HCSummaryComment -Details $details -OkMessage "$($dcs.Count) DCs, $($details.Count) links replicating (max age $maxAge min)"
    New-HCResult -Status (Get-HCWorstStatus $details.State) -Comments $comment -Details $details
}

A complete, drop-in check. It queries every domain controller's inbound replication partners and flags anything with a non-zero result or a stale last-success time.

Test it on its own before wiring it into anything else. Open pwsh and run:

cd C:\HealthCheck
Import-Module .\modules\HealthCheck.Common.psm1 -Force
. .\checks\01-Test-ADReplication.ps1
$result = Test-HCADReplication -Settings @{ MaxReplicationAgeMinutes = 180 }
$result | Format-List
Calling a single check function directly is the fastest way to confirm it works before wiring it into the full orchestrator.
Calling a single check function directly is the fastest way to confirm it works before wiring it into the full orchestrator.

Step 4 – A Second Check: SMTP Validation

A second check that needs no special credentials at all – just network access. This one opens a raw connection to your mail relay and reads the standard SMTP replies, without actually sending an email.

Save this as checks\02-Test-SmtpServer.ps1:

<#
 Check: SMTP server validation
 Needs: network access to the SMTP endpoint(s). No credentials, no email sent.
#>
function Read-HCSmtpResponse {
    param([System.IO.StreamReader]$Reader)
    $lines = @()
    do { $line = $Reader.ReadLine(); if ($null -eq $line) { throw 'Connection closed' }; $lines += $line }
    while ($line.Length -ge 4 -and $line[3] -eq '-')
    [pscustomobject]@{ Code = [int]$line.Substring(0,3); Lines = $lines }
}
 
function Test-HCSmtpServer {
    param($Settings)
    $details = foreach ($e in $Settings.Endpoints) {
        $label = "$($e.Server):$($e.Port)"
        $client = [System.Net.Sockets.TcpClient]::new()
        try {
            $iar = $client.BeginConnect($e.Server, [int]$e.Port, $null, $null)
            if (-not $iar.AsyncWaitHandle.WaitOne(10000)) {
                New-HCDetail -Target $label -Check 'TCP connect' -Value 'Timeout' -State FAILED; continue
            }
            $client.EndConnect($iar)
            $stream = $client.GetStream(); $stream.ReadTimeout = 10000
            $reader = [System.IO.StreamReader]::new($stream)
            $writer = [System.IO.StreamWriter]::new($stream); $writer.NewLine = "`r`n"; $writer.AutoFlush = $true
 
            $banner = Read-HCSmtpResponse $reader
            New-HCDetail -Target $label -Check 'Banner' -Value $banner.Lines[0] -State ($(if ($banner.Code -eq 220) {'OK'} else {'FAILED'}))
 
            $writer.WriteLine("EHLO healthcheck")
            $ehlo = Read-HCSmtpResponse $reader
            New-HCDetail -Target $label -Check 'EHLO' -Value "$($ehlo.Code)" -State ($(if ($ehlo.Code -eq 250) {'OK'} else {'FAILED'}))
            $writer.WriteLine('QUIT')
        }
        catch { New-HCDetail -Target $label -Check 'SMTP dialogue' -Value $_.Exception.Message -State FAILED }
        finally { $client.Dispose() }
    }
    $details = @($details)
    $comment = Get-HCSummaryComment -Details $details -OkMessage "$($Settings.Endpoints.Count) endpoint(s) responding (220/250)"
    New-HCResult -Status (Get-HCWorstStatus $details.State) -Comments $comment -Details $details
}

Opens a TCP connection, reads the 220 banner and 250 EHLO reply, then disconnects – no message is ever actually sent.

Two working checks is enough to prove the pattern. From here, every additional check – file shares, scheduled tasks, service status, cloud backups – follows exactly the same shape: query something real, build New-HCDetail rows, roll them into one New-HCResult.

Step 5 – The Orchestrator

This is the one script you actually run – it loads the shared module and every check, reads your configuration, runs each enabled check safely, builds the report, and emails it.

Save this as Invoke-DailyHealthCheck.ps1:

#Requires -Version 7.2
[CmdletBinding()]
param(
    [string]$ConfigPath = (Join-Path $PSScriptRoot 'config\HealthCheck.config.json'),
    [string[]]$Only,
    [switch]$NoEmail
)
$ErrorActionPreference = 'Stop'
Import-Module (Join-Path $PSScriptRoot 'modules\HealthCheck.Common.psm1') -Force
Get-ChildItem (Join-Path $PSScriptRoot 'checks') -Filter '*.ps1' | ForEach-Object { . $_.FullName }
 
$config  = Get-Content $ConfigPath -Raw | ConvertFrom-Json
$runDate = Get-Date
$outDir  = Join-Path $config.General.OutputRoot ($runDate.ToString('yyyy\MM\dd'))
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
Start-HCLog -Path (Join-Path $outDir 'HealthCheck.log')
 
# ---------------------------------------------------------------------
# Catalogue - one entry per check. Add a new line here every time you
# add a new checks\*.ps1 file. Key must match the config.json section name.
# ---------------------------------------------------------------------
$catalogue = @(
    @{ Id = 1; Name = 'Directory replication health check'; Key = 'ADReplication';  Function = 'Test-HCADReplication' }
    @{ Id = 2; Name = 'SMTP server validation';              Key = 'SmtpServer';     Function = 'Test-HCSmtpServer' }
    # add more checks here as you build them
)
 
function Test-Enabled([hashtable]$Item) {
    if ($Only -and $Only -notcontains $Item.Key) { return $false }
    $s = $config.Checks.($Item.Key)
    return ($s -and $s.Enabled)
}

Part 1 – setup and the check catalogue. Every check is listed once here, mapping its config key to the function that implements it.

# Sign in once if any enabled check needs Azure or Graph
$enabledKeys = $catalogue | Where-Object { Test-Enabled $_ } | ForEach-Object { $_.Key }
if ($enabledKeys | Where-Object { $_ -in 'AzureBackup' }) {
    try { Connect-HCAzure -Auth $config.Auth } catch { Write-HCLog "Azure sign-in failed: $($_.Exception.Message)" -Level ERROR }
}
if ($config.Mail.Enabled -and -not $NoEmail) {
    try { Connect-HCGraph -Auth $config.Auth } catch { Write-HCLog "Graph sign-in failed: $($_.Exception.Message)" -Level ERROR }
}
 
# Run every enabled check, safely, one at a time
$results = foreach ($item in $catalogue) {
    if (Test-Enabled $item) {
        $fn = $item.Function
        $settings = $config.Checks.($item.Key)
        Invoke-HCCheck -Id $item.Id -Name $item.Name -Action { & $fn -Settings $settings }
    }
    else {
        New-HCResult -Id $item.Id -Name $item.Name -Status MANUAL -Comments 'Not automated yet'
    }
}
$results = @($results)
 
# Save evidence and build the report
$stamp = $runDate.ToString('yyyyMMdd_HHmm')
$results | ConvertTo-Json -Depth 6 | Set-Content (Join-Path $outDir "HealthCheck_$stamp.json")
$html = ConvertTo-HCHtmlReport -Results $results -RunDate $runDate
$html | Set-Content (Join-Path $outDir "HealthCheck_$stamp.html")
 
$overall = Get-HCWorstStatus (($results | Where-Object Status -ne 'MANUAL').Status)
$subject = 'SYSTEMS DAILY HEALTH CHECK ({0})' -f $runDate.ToString('dd-MM-yyyy')
if ($overall -in 'FAILED','ERROR') { $subject += " - ATTENTION: issue(s) found" }
 
if ($config.Mail.Enabled -and -not $NoEmail) {
    try { Send-HCReport -MailConfig $config.Mail -Subject $subject -Html $html }
    catch { Write-HCLog "Mail failed: $($_.Exception.Message)" -Level ERROR }
}
 
Write-HCLog "Finished. Overall: $overall"
$results | Select-Object Id, Name, Status, Comments | Format-Table -AutoSize -Wrap
exit $(if ($overall -in 'FAILED','ERROR') { 1 } else { 0 })

Part 2 – execution, evidence, and email. The exit code at the very end is what lets Task Scheduler (or any monitoring on top) flag a bad run automatically.

Try it end to end

With both check scripts and the orchestrator in place, run a full test without sending email:

cd C:\HealthCheck
.\Invoke-DailyHealthCheck.ps1 -NoEmail

Open the HTML file it creates under Reports\yyyy\MM\dd\ – that's your first automatically generated report.

Step 6 – The Configuration File

One JSON file holds every setting: where reports are saved, how email is sent, and the specific servers/thresholds for each check. This is the only file you'll normally need to edit as you add more checks or change an environment detail.

Save this as config\HealthCheck.config.json:

{
  "General": {
    "OutputRoot": "C:\\HealthCheck\\Reports",
    "RetentionDays": 90
  },
  "Auth": {
    "TenantId": "<your-tenant-id>",
    "ClientId": "<your-app-registration-client-id>",
    "CertificateThumbprint": "<thumbprint-of-the-cert-you-generated>"
  },
  "Mail": {
    "Enabled": true,
    "From": "healthcheck@yourdomain.com",
    "To": [ "your-team-distribution-list@yourdomain.com" ]
  },
  "Checks": {
    "ADReplication": {
      "Enabled": true,
      "Domain": "",
      "MaxReplicationAgeMinutes": 180
    },
    "SmtpServer": {
      "Enabled": true,
      "Endpoints": [
        { "Server": "your-smtp-relay.yourdomain.com", "Port": 25 }
      ]
    }
  }
}

A minimal, working config covering the two checks built so far. Add a new block under Checks every time you add a new check script.

Tip: after any manual edit to this file, validate it before running anything else – a single missing comma will stop the whole automation with a cryptic JSON parser error. One line catches it every time:

Get-Content .\config\HealthCheck.config.json -Raw | ConvertFrom-Json | Out-Null

Step 7 – Adding a Cloud Check (Azure Backup Example)

Checks that query on-premises systems only need a domain account. The moment a check needs to query Azure or Microsoft Graph (for example, to check cloud backup status, or to send the report by email), you need a dedicated app registration – a separate, certificate-based identity for the automation, completely independent of any person's login.

7.1 – Register the app

  1. Go to the Entra admin center (or Azure Portal) → App registrations → New registration.
  2. Give it a clear, self-documenting name – something like healthcheck-automation, not a person's name.
  3. Choose “Accounts in this organizational directory only (Single tenant).”
  4. Click Register, then copy the Application (client) ID and Directory (tenant) ID from the Overview page – both go into your config file.
Registering a dedicated app identity for the automation – never reuse a personal account for this.
Registering a dedicated app identity for the automation – never reuse a personal account for this.

7.2 – Generate a certificate (no passwords)

Certificate-based authentication means there's no password to leak or rotate. Generate it on the automation server itself, logged in as the service account that will actually run the scheduled task – not your own admin account:

$cert = New-SelfSignedCertificate -Subject "CN=HealthCheckAutomation" `
    -CertStoreLocation "Cert:\CurrentUser\My" -KeyExportPolicy Exportable `
    -KeySpec Signature -KeyLength 2048 -KeyAlgorithm RSA -HashAlgorithm SHA256
 
Export-Certificate -Cert $cert -FilePath "C:\HealthCheck\certs\HealthCheckAutomation.cer"
 
# Get the thumbprint - this goes into your config file
Get-ChildItem Cert:\CurrentUser\My | Where-Object Subject -eq "CN=HealthCheckAutomation" |
    Select-Object Thumbprint, NotAfter

Important: if you generate this certificate while logged in as yourself rather than the service account, the scheduled task won't be able to use its private key later when it runs unattended. Always generate it under the exact account that will run the task.

7.3 – Upload the certificate and grant permissions

  1. In the app registration → Certificates & secrets → Certificates tab → Upload certificate → select the .cer file (never the private key).
  2. API permissions → Add a permission → Microsoft Graph → Application permissions → search and add Mail.Send (if this app will send the report) and/or Organization.Read.All (for identity sync checks).
  3. Click Grant admin consent – this step requires administrator rights and won't take effect without it.
  4. For Azure resource checks (like backup status), go to the relevant subscription or resource group → Access control (IAM) → Add role assignment → choose a read-only role like Backup Reader or Reader → assign it to your app registration (search by its name, and confirm the Type column shows “App,” not “User” – it's an easy mix-up).

7.4 – A simplified cloud check

Here's a trimmed-down example that lists Azure Recovery Services vaults directly through the REST API – calling the API directly, rather than through a higher-level module, turned out to be the more reliable approach in practice (more on why in the troubleshooting section later).

<#
 Check: Azure backup vault visibility (simplified example)
 Needs: Az.Accounts module, and a read-only role (e.g. Backup Reader)
        granted to the app registration on the subscription.
#>
function Get-HCArmToken {
    $tokenObj = Get-AzAccessToken -ResourceUrl 'https://management.azure.com/'
    if ($tokenObj.Token -is [System.Security.SecureString]) {
        [Runtime.InteropServices.Marshal]::PtrToStringAuto(
            [Runtime.InteropServices.Marshal]::SecureStringToBSTR($tokenObj.Token))
    } else { $tokenObj.Token }
}
 
function Test-HCAzureBackup {
    param($Settings)
    $headers = @{ Authorization = "Bearer $(Get-HCArmToken)" }
    $details = foreach ($subId in $Settings.Subscriptions) {
        $uri = "https://management.azure.com/subscriptions/$subId/providers/Microsoft.RecoveryServices/vaults?api-version=2023-04-01"
        try {
            $vaults = (Invoke-RestMethod -Uri $uri -Headers $headers -Method GET).value
            foreach ($v in $vaults) {
                New-HCDetail -Target $v.name -Check 'Vault visible' -Value $v.location -State OK
            }
        }
        catch { New-HCDetail -Target $subId -Check 'Vault list' -Value $_.Exception.Message -State ERROR }
    }
    $details = @($details)
    New-HCResult -Status (Get-HCWorstStatus $details.State) `
        -Comments "$($details.Count) vault(s) visible" -Details $details
}

A deliberately simplified starting point – the real version in production also checks each vault's protected items and recent backup jobs, following exactly this same pattern of calling the REST API directly.

Add the matching block to your config file's Auth section with your real tenant ID, client ID, and certificate thumbprint, plus a Checks.AzureBackup block listing your subscription ID(s) – then add one more line to the orchestrator's catalogue, exactly like the first two checks.

Step 8 – Scheduling It To Run Automatically

Once you're happy with the checks running manually, lock in the daily schedule. Save this as Register-HealthCheckTask.ps1:

#Requires -RunAsAdministrator
param(
    [Parameter(Mandatory)][string]$RunAsUser,
    [string]$At = '07:30',
    [string]$ScriptRoot = $PSScriptRoot,
    [string]$TaskName = 'Systems Daily Health Check'
)
$pwsh   = (Get-Command pwsh.exe -ErrorAction Stop).Source
$script = Join-Path $ScriptRoot 'Invoke-DailyHealthCheck.ps1'
$cred   = Get-Credential -UserName $RunAsUser -Message 'Password for the health-check service account'
 
$action   = New-ScheduledTaskAction -Execute $pwsh `
    -Argument "-NoProfile -NonInteractive -ExecutionPolicy RemoteSigned -File `\"$script`\"" `
    -WorkingDirectory $ScriptRoot
$trigger  = New-ScheduledTaskTrigger -Daily -At $At
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 1) `
    -StartWhenAvailable -RestartCount 2 -RestartInterval (New-TimeSpan -Minutes 10)
 
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Settings $settings `
    -User $cred.UserName -Password $cred.GetNetworkCredential().Password -RunLevel Limited -Force
 
Write-Host "Scheduled task '$TaskName' registered to run daily at $At as $RunAsUser"

Run this once, as an administrator, after your checks are tested and working.

Register it with your own domain and service account name – check your server's NetBIOS domain name first with $env:USERDOMAIN if you're not certain of it:

.\Register-HealthCheckTask.ps1 -RunAsUser 'YOURDOMAIN\svc-healthcheck' -At '07:30'

Once registered, the task shows up in Task Scheduler exactly like any other scheduled job – “Ready” status, a daily trigger, and the last run result.
Once registered, the task shows up in Task Scheduler exactly like any other scheduled job – “Ready” status, a daily trigger, and the last run result.

Confirm the timezone is what you expect – Windows interprets the trigger time using the server's own local timezone, not UTC:

Get-TimeZone
Get-ScheduledTask -TaskName "Systems Daily Health Check" | Select-Object -ExpandProperty Triggers

What The Finished Report Looks Like

Once everything is wired together and running on schedule, this is what lands in the team's inbox every morning – the same familiar report layout, except every row is now filled in by a live query instead of someone's memory of what they saw on a screen.

The automation running on its own schedule – each check queries a real system and logs a plain OK / WARNING / FAILED result.
The automation running on its own schedule – each check queries a real system and logs a plain OK / WARNING / FAILED result.
The resulting email report – any row can flip to WARNING or FAILED the moment a real problem is detected.
The resulting email report – any row can flip to WARNING or FAILED the moment a real problem is detected.

Step 9 (Advanced) – Securing Access To Sensitive Servers

Some checks need to query a genuinely sensitive system – a domain controller is the classic example. Adding your service account to a privileged security group permanently works, but it means that account holds elevated rights around the clock, every day, whether the health check is running or not.

Just Enough Administration (JEA) offers a cleaner alternative: a constrained PowerShell endpoint that exposes only the exact commands a check needs, and temporarily borrows elevated rights only for the length of one session – with every single command transcript-logged for audit.

9.1 – Define exactly what's visible

Save this as HealthCheckEndpoint.psrc on the sensitive server:

@{
    Author = 'Your Name'
    Description = 'Read-only visibility for the daily health check automation.'
    ModulesToImport = @('ADSync')   # or whatever module the check needs
 
    # Only these commands exist inside this session - nothing else is callable
    VisibleCmdlets = @(
        'Get-Service'
        @{ Name = 'Get-ADSyncScheduler';        Module = 'ADSync' }
        @{ Name = 'Get-ADSyncRunProfileResult'; Module = 'ADSync' }
    )
    VisibleFunctions = @()
    VisibleExternalCommands = @()
    LanguageMode = 'ConstrainedLanguage'
}

The Role Capability file – this list is the entire command surface available once connected.

9.2 – Define who can connect, and with what temporary privilege

Save this as HealthCheckEndpoint.pssc:

@{
    SchemaVersion = '2.0.0.0'
    GUID          = '<generate-a-new-guid-here>'
    SessionType   = 'RestrictedRemoteServer'
    TranscriptDirectory = 'C:\ProgramData\JEAConfiguration\Transcripts'
 
    # The core idea: the connecting account borrows this group membership
    # ONLY for the duration of the session - no permanent group membership anywhere
    RunAsVirtualAccount       = $true
    RunAsVirtualAccountGroups = @('ADSyncOperators')
 
    RoleDefinitions = @{
        'YOURDOMAIN\svc-healthcheck' = @{ RoleCapabilities = @('HealthCheckEndpoint') }
    }
}

The Session Configuration file – only the named account can connect, and only to this exact, narrow capability set.

9.3 – Register the endpoint

Run once, on the sensitive server, as an administrator (not as the service account):

$roleCapDir = "$env:ProgramFiles\WindowsPowerShell\Modules\HealthCheckJEA\RoleCapabilities"
New-Item -ItemType Directory -Path $roleCapDir -Force | Out-Null
Copy-Item .\HealthCheckEndpoint.psrc -Destination $roleCapDir -Force
New-Item -ItemType Directory -Path 'C:\ProgramData\JEAConfiguration\Transcripts' -Force | Out-Null
 
Register-PSSessionConfiguration -Path .\HealthCheckEndpoint.pssc -Name HealthCheckEndpoint -Force
Restart-Service WinRM -Force

From your check script, connect through the named endpoint instead of a plain session:

Invoke-Command -ComputerName <server> -ConfigurationName HealthCheckEndpoint -ScriptBlock {
    Get-Service ADSync
}

This is worth the extra setup specifically for sensitive systems – domain controllers, certificate authorities, anything where your security team would reasonably push back on a service account holding standing privileged access. For an ordinary file or application server, a simple dedicated local group is usually proportionate.

Troubleshooting Patterns Worth Knowing

A few genuinely useful lessons surfaced while building this that apply well beyond this specific project – they're common patterns in PowerShell and cloud API work generally.

Symptom

What actually helped

A well-documented module returns empty results for a correctly permissioned identity

Don't keep tuning permissions. Drop a level and call the REST API directly with the same access token – this exposes whether it's really a permissions problem or a module bug.

A cloud API's date filter seems to have no effect at all

Some REST APIs silently ignore certain filter combinations rather than erroring. Pull broadly using only a filter you've proven works, then filter precisely yourself in code.

“Access is denied” persists for several minutes after a permission was correctly granted

Before assuming the permission is wrong, disconnect and reconnect to get a completely fresh authentication token. A cached token from before the permission existed will keep failing even after the grant is live.

A JSON config file suddenly fails to parse after a manual edit

Validate immediately with Get-Content ... | ConvertFrom-Json | Out-Null. A single missing comma is the most common cause, and this one line finds it instantly instead of guessing.

A whole section of a report silently goes missing with no error at all

In PowerShell, output inside a function that isn't explicitly captured into a variable becomes the function's own return value instead of being added where you expected. Always explicitly capture loop output: $rows = foreach (...) { ... }.

Two versions of the same check script both exist in the checks folder

The orchestrator loads every .ps1 file it finds – whichever loads last silently wins, with no warning. Keep the folder clean; don't leave old copies around.

Where To Take It From Here

With the pattern established – shared module, one script per check, orchestrator, schedule – extending this is just a matter of repetition. A few natural next additions:

  • File share health – confirm a share is reachable, writable, and has adequate free space.
  • Scheduled task monitoring – confirm a critical job actually ran recently and succeeded.
  • Service availability – confirm a Windows service is running and set to start automatically.
  • Multi-cloud coverage – the same backup-status pattern, pointed at a second cloud provider's API.
  • Richer notifications – flag the email subject line itself when something needs attention, so nobody has to open the report to know.

Every one of these follows the exact same three-part shape used throughout this guide: query something real, build New-HCDetail rows describing what you found, and roll them into one New-HCResult. Once that pattern clicks, adding the tenth check takes a fraction of the time the first one did.

The most valuable part of this project wasn't the PowerShell itself – it was the discipline of treating every unexpected result as information rather than an obstacle. A module that mysteriously returns nothing, a permission that doesn't seem to take effect, a report section that silently vanishes – each one has a precise, discoverable cause, and finding it beats guessing every time.

Have a similarly repetitive process on your own team? It's worth asking whether it actually needs a human, or just needs someone to ask Copilot the right first question.

#Microsoft365Copilot#PowerShell#Automation #InfrastructureMonitoring#MicrosoftGraph#Azure #CloudOperations#DevOps

Downloads

Resume and other downloadable resources.

📄 Resume

Download my full resume as a PDF, covering 15+ years of cloud consulting, solution architecture, and infrastructure engineering experience.

⬇ Download Resume (PDF)