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
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.
Experience Timeline
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.
Led a 12-member delivery team; implemented Azure DevOps CI/CD pipelines; delivered VMware/Hyper-V to Azure migrations via Azure Migrate, Corent & ASR.
Architected Azure hosting solutions for enterprise clients; deployed AKS as PaaS; supported pre-sales (RFP/RFQ) for large hosting bids.
Managed 400+ virtual servers across Azure, Alibaba, VMware & Hyper-V; administered AD and Office 365 for multiple tenants.
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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 ...
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 ...
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.
Understanding the Microsoft Copilot Ecosystem
Which Copilot should you use? A breakdown of all seven Microsoft Copilot offerings.
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
| Need | Recommended Copilot |
|---|---|
| General AI Chat (Free of Cost) | Copilot Chat |
| Microsoft 365 Productivity | Microsoft 365 Copilot |
| Software Development | GitHub Copilot |
| Cybersecurity | Security Copilot |
| Custom AI Agents | Copilot Studio |
| Business Automation | Power Platform Copilot |
| Analytics & Reporting | Power 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.
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.
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.shexecutes without manual intervention - ✓ Docker image rebuilds with the latest code
- ✓ Container restarts and serves the update instantly
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
Lessons Learned
- Always verify
git statusshows 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.
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.
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:
- A shared module with common building blocks – result objects, logging, authentication helpers, and the HTML report generator.
- One independent script per health check – each one queries a real system and returns a simple OK / WARNING / FAILED status.
- An orchestrator that loads every check, runs them safely, builds the report, and emails it.
- A Windows Scheduled Task that runs the orchestrator automatically, every morning, with no one watching.

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 evidenceThe 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
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
- Go to the Entra admin center (or Azure Portal) → App registrations → New registration.
- Give it a clear, self-documenting name – something like healthcheck-automation, not a person's name.
- Choose “Accounts in this organizational directory only (Single tenant).”
- Click Register, then copy the Application (client) ID and Directory (tenant) ID from the Overview page – both go into your config file.

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, NotAfterImportant: 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
- In the app registration → Certificates & secrets → Certificates tab → Upload certificate → select the .cer file (never the private key).
- 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).
- Click Grant admin consent – this step requires administrator rights and won't take effect without it.
- 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'

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.


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.
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)