Automating macOS Setup and Backups with Homebrew, Dotfiles, and Bootstrap Scripts

Overview This guide walks through my automated macOS setup and backup workflow. The goals: Quickly set up a new Mac by running a single bootstrap script. Avoid manually remembering installed apps, preferences, and configs. Automatically back up my dotfiles and Homebrew installs to a GitHub repo. Include App Store apps, macOS preferences, and even my AirPrint printer. Core Components 1. Homebrew Bundle I use Homebrew’s brew bundle dump to capture all my brew formulas, casks, and MAS (Mac App Store) apps into a Brewfile. ...

3 min · Me

Automating NewRelic Agent Updates in Windows Environments

The Challenge of Agent Updates Keeping monitoring agents up-to-date is very important for maintaining effective observability in your systems. However, manual updates can be time-consuming and often get overlooked. This post demonstrates a very simple way of how to automate NewRelic agent updates in a Windows environment. Infrastructure Agent Update Script Create a PowerShell script to update the NewRelic Infrastructure agent: Stop-Service -Name “newrelic-infra” (New-Object System.Net.WebClient).DownloadFile( “https://download.newrelic.com/infrastructure_agent/windows/newrelic-infra.msi", “$env:TEMP\newrelic-infra.msi” ) msiexec.exe /qn /i “$env:TEMP\newrelic-infra.msi” Start-Service -Name “newrelic-infra” ...

2 min · Me

Implementing Automated Azure Resource Locks with PowerShell Runbooks

Why Resource Locks Matter Azure resource locks are an important security feature that prevent accidental deletion or modification of important resources. However, manual implementation can be tedious and locks may be inadvertently removed, forgotten to put back, or not added to new resources. This post explains how to automate the process using Azure Automation runbooks. Implementation Overview Creating the Automation Runbook Create a new Azure Automation account or use an existing one Create a PowerShell runbook that will: Scan for resources without locks Apply appropriate lock types (CanNotDelete or ReadOnly) Skip dynamic resources like AKS nodes Sample PowerShell Script # Azure Resource Lock Automation Script - Fixed Authentication # This script processes locks in batches to avoid timeout limits #Requires -Module Az.Accounts, Az.Resources param( [string[]]$SubscriptionIds = @(), [string[]]$ExemptResourceGroups = @(), [string[]]$ExemptResources = @(), [string]$LockName = "DenyDelete", [string]$LockNotes = "Delete lock", [switch]$WhatIf = $false, [switch]$IncludeResources = $true, [int]$BatchSize = 10, [int]$MaxExecutionMinutes = 150, [string]$StateTableName = "LockAutomationState", [string]$StorageAccountName = "", [string]$StorageResourceGroup = "", [string[]]$TargetResourceTypes = @( "Microsoft.Compute/virtualMachines", "Microsoft.Compute/virtualMachineScaleSets", "Microsoft.Sql/servers", "Microsoft.Sql/managedInstances", "Microsoft.DBforPostgreSQL/servers", "Microsoft.DBforMySQL/servers", "Microsoft.DBforMariaDB/servers", "Microsoft.DocumentDB/databaseAccounts", "Microsoft.Storage/storageAccounts", "Microsoft.Network/virtualNetworks", "Microsoft.Network/networkSecurityGroups", "Microsoft.Network/routeTables", "Microsoft.Network/publicIPAddresses", "Microsoft.Network/loadBalancers", "Microsoft.Network/applicationGateways", "Microsoft.Network/dnszones", "Microsoft.Network/privateDnsZones", "Microsoft.KeyVault/vaults", "Microsoft.RecoveryServices/vaults", "Microsoft.ContainerRegistry/registries", "Microsoft.Kubernetes/connectedClusters", "Microsoft.ContainerService/managedClusters", "Microsoft.Web/sites", "Microsoft.Web/serverfarms", "Microsoft.Logic/workflows", "Microsoft.DataFactory/factories", "Microsoft.Synapse/workspaces", "Microsoft.Network/natGateways", "Microsoft.Network/vpnGateways", "Microsoft.Purview/accounts", "Microsoft.Security/pricings", "Microsoft.OperationsManagement/solutions" ) ) # Global variables for tracking $script:StartTime = Get-Date $script:ProcessedCount = 0 $script:SuccessCount = 0 $script:SkippedCount = 0 $script:ErrorCount = 0 $script:BatchNumber = 0 $script:TimeoutReached = $false # Check execution time limit function Test-ExecutionTimeLimit { $elapsed = (Get-Date) - $script:StartTime $remainingMinutes = $MaxExecutionMinutes - $elapsed.TotalMinutes if ($remainingMinutes -le 5) { # Stop with 5 minutes buffer $script:TimeoutReached = $true Write-Output "⚠️ TIMEOUT WARNING: Only $([math]::Round($remainingMinutes, 1)) minutes remaining. Stopping execution." return $false } return $true } function Write-BatchSummary { param( [int]$BatchNum, [string]$SubscriptionId, [int]$BatchProcessed, [int]$BatchSuccess, [int]$BatchSkipped, [int]$BatchErrors, [datetime]$BatchStartTime ) $batchElapsed = (Get-Date) - $BatchStartTime $totalElapsed = (Get-Date) - $script:StartTime Write-Output "`n📊 --- Batch $BatchNum Summary ---" Write-Output "🎯 Subscription: $SubscriptionId" Write-Output "📋 Batch processed: $BatchProcessed" Write-Output "✅ Batch successful: $BatchSuccess" Write-Output "⏭️ Batch skipped: $BatchSkipped" Write-Output "❌ Batch errors: $BatchErrors" Write-Output "⏱️ Batch time: $([math]::Round($batchElapsed.TotalMinutes, 1)) minutes" Write-Output "🕐 Total elapsed: $([math]::Round($totalElapsed.TotalMinutes, 1)) minutes" Write-Output "⏳ Remaining time: $([math]::Round($MaxExecutionMinutes - $totalElapsed.TotalMinutes, 1)) minutes" Write-Output "📊 --- End Batch Summary ---`n" } # Simplified state management (in-memory for this version) $script:ProcessedResourceGroups = @{} function Connect-ToAzure { try { Write-Output "🔍 Checking Azure authentication status..." # Check if we already have a PowerShell Az context $context = Get-AzContext -ErrorAction SilentlyContinue if ($context) { Write-Output "✓ Already connected to Azure PowerShell as: $($context.Account.Id)" Write-Output "✓ Current subscription: $($context.Subscription.Name) ($($context.Subscription.Id))" return $true } Write-Output "⚠️ No existing Azure PowerShell context found" # Check if we're running in Azure Automation (has specific environment variables) $isAzureAutomation = $env:AUTOMATION_ASSET_ACCOUNTID -or $env:AUTOMATION_RESOURCE_GROUP if ($isAzureAutomation) { # Try to connect using Managed Identity (for Azure Automation) try { Write-Output "🔄 Detected Azure Automation environment. Attempting to connect using Managed Identity..." Connect-AzAccount -Identity -ErrorAction Stop $context = Get-AzContext Write-Output "✓ Successfully connected using Managed Identity" Write-Output "✓ Connected as: $($context.Account.Id)" return $true } catch { Write-Output "❌ Managed Identity connection failed: $($_.Exception.Message)" } } else { # Running locally - try to import Azure CLI credentials Write-Output "🔄 Running locally. Attempting to import Azure CLI credentials..." try { # Try to connect using Azure CLI credentials Connect-AzAccount -UseDeviceAuthentication:$false -ErrorAction Stop $context = Get-AzContext Write-Output "✓ Successfully connected using existing credentials" Write-Output "✓ Connected as: $($context.Account.Id)" return $true } catch { Write-Output "❌ Failed to connect using existing credentials: $($_.Exception.Message)" # Last resort - ask user to connect manually Write-Output "💡 Please run 'Connect-AzAccount' first to authenticate to Azure PowerShell" Write-Output "💡 Note: 'az login' is for Azure CLI, but this script requires Azure PowerShell authentication" Write-Error "Authentication required. Please run 'Connect-AzAccount' before running this script." return $false } } Write-Error "Authentication required. Please authenticate to Azure before running this script." return $false } catch { Write-Error "Failed to connect to Azure: $($_.Exception.Message)" return $false } } function Add-ResourceGroupLockOptimized { param( [string]$SubscriptionId, [string]$ResourceGroupName, [string]$LockName, [string]$Notes, [bool]$WhatIf ) try { $script:ProcessedCount++ # Quick check for existing lock $existingLock = Get-AzResourceLock -ResourceGroupName $ResourceGroupName -ErrorAction SilentlyContinue | Where-Object { $_.Properties.level -eq "CanNotDelete" } | Select-Object -First 1 if ($existingLock) { Write-Output "✓ RG '$ResourceGroupName' already locked" $script:SkippedCount++ return } if ($WhatIf) { Write-Output "WHATIF: Would lock RG '$ResourceGroupName'" return } New-AzResourceLock -ResourceGroupName $ResourceGroupName -LockName $LockName -LockLevel CanNotDelete -LockNotes $Notes -Force -ErrorAction Stop Write-Output "✓ Locked RG '$ResourceGroupName'" $script:SuccessCount++ } catch { Write-Warning "Failed to lock RG '$ResourceGroupName': $($_.Exception.Message)" $script:ErrorCount++ } } function Add-ResourceLockOptimized { param( [object]$Resource, [string]$LockName, [string]$Notes, [bool]$WhatIf, [string[]]$TargetResourceTypes ) try { # Quick type check if ($Resource.ResourceType -notin $TargetResourceTypes) { return } $script:ProcessedCount++ # Quick check for existing lock $existingLock = Get-AzResourceLock -ResourceName $Resource.Name -ResourceType $Resource.ResourceType -ResourceGroupName $Resource.ResourceGroupName -ErrorAction SilentlyContinue | Where-Object { $_.Properties.level -eq "CanNotDelete" } | Select-Object -First 1 if ($existingLock) { $script:SkippedCount++ return } if ($WhatIf) { Write-Output "WHATIF: Would lock resource '$($Resource.Name)' ($($Resource.ResourceType))" return } New-AzResourceLock -ResourceName $Resource.Name -ResourceType $Resource.ResourceType -ResourceGroupName $Resource.ResourceGroupName -LockName $LockName -LockLevel CanNotDelete -LockNotes $Notes -Force -ErrorAction Stop Write-Output "✓ Locked resource '$($Resource.Name)' ($($Resource.ResourceType))" $script:SuccessCount++ } catch { Write-Warning "Failed to lock resource '$($Resource.Name)': $($_.Exception.Message)" $script:ErrorCount++ } } function Test-ResourceGroupExemption { param([string]$ResourceGroupName, [string[]]$ExemptList) foreach ($exemption in $ExemptList) { if ($ResourceGroupName -like $exemption) { return $true } } return $false } # Main execution try { Write-Output "=== Azure Resource Lock Automation - Batch Mode ===" Write-Output "Started at: $(Get-Date)" Write-Output "Max execution time: $MaxExecutionMinutes minutes" Write-Output "Batch size: $BatchSize resource groups" # Connect to Azure (or verify existing connection) if (-not (Connect-ToAzure)) { Write-Error "Failed to establish Azure connection. Exiting." exit 1 } # Get subscriptions if ($SubscriptionIds.Count -eq 0) { Write-Output "📋 No specific subscriptions provided. Getting all enabled subscriptions..." Write-Output "🔄 Querying Azure for enabled subscriptions..." $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } $SubscriptionIds = $subscriptions.Id Write-Output "✓ Found $($SubscriptionIds.Count) enabled subscriptions" # List the subscriptions we found foreach ($sub in $subscriptions) { Write-Output " - $($sub.Name) ($($sub.Id))" } } else { Write-Output "📋 Using provided subscription IDs: $($SubscriptionIds.Count) subscription(s)" } Write-Output "`n🚀 Starting processing of $($SubscriptionIds.Count) subscription(s)..." foreach ($subscriptionId in $SubscriptionIds) { # Check time limit before each subscription if (-not (Test-ExecutionTimeLimit)) { Write-Output "Time limit reached. Stopping subscription processing." break } Write-Output "`n🎯 === Processing Subscription: $subscriptionId ===" try { # Set context to the subscription Write-Output "🔄 Setting Azure context to subscription..." $context = Set-AzContext -SubscriptionId $subscriptionId -ErrorAction Stop Write-Output "✓ Set context to subscription: $($context.Subscription.Name)" # Get resource groups Write-Output "📦 Getting resource groups from subscription..." $resourceGroups = Get-AzResourceGroup -ErrorAction Stop Write-Output "✓ Found $($resourceGroups.Count) resource groups in subscription" if ($resourceGroups.Count -eq 0) { Write-Output "⚠️ No resource groups found in this subscription. Skipping..." continue } # Show exemption info if any if ($ExemptResourceGroups.Count -gt 0) { Write-Output "🚫 Exempted resource group patterns: $($ExemptResourceGroups -join ', ')" } Write-Output "⏳ Processing resource groups in batches of $BatchSize..." # Process in batches for ($i = 0; $i -lt $resourceGroups.Count; $i += $BatchSize) { # Check time limit before each batch if (-not (Test-ExecutionTimeLimit)) { break } $script:BatchNumber++ $batchStartTime = Get-Date $batchProcessed = 0 $batchSuccess = 0 $batchSkipped = 0 $batchErrors = 0 $batch = $resourceGroups | Select-Object -Skip $i -First $BatchSize Write-Output "📋 Processing batch $($script:BatchNumber): RGs $($i + 1) to $($i + $batch.Count) of $($resourceGroups.Count)" Write-Output "⏱️ Batch started at: $(Get-Date -Format 'HH:mm:ss')" foreach ($rg in $batch) { Write-Output "🔍 Processing RG: $($rg.ResourceGroupName)" # Check time limit during batch processing if (-not (Test-ExecutionTimeLimit)) { Write-Output "⏰ Time limit reached during batch processing. Stopping current batch." break } # Check exemptions if (Test-ResourceGroupExemption -ResourceGroupName $rg.ResourceGroupName -ExemptList $ExemptResourceGroups) { Write-Output "🚫 Skipping exempted RG: $($rg.ResourceGroupName)" $batchSkipped++ continue } # Track counts before processing $beforeProcessed = $script:ProcessedCount $beforeSuccess = $script:SuccessCount $beforeSkipped = $script:SkippedCount $beforeErrors = $script:ErrorCount # Lock resource group Write-Output "🔒 Checking/adding lock for RG: $($rg.ResourceGroupName)" Add-ResourceGroupLockOptimized -SubscriptionId $subscriptionId -ResourceGroupName $rg.ResourceGroupName -LockName $LockName -Notes $LockNotes -WhatIf $WhatIf # Lock individual resources if enabled if ($IncludeResources) { try { Write-Output "📦 Getting resources from RG: $($rg.ResourceGroupName)" $resources = Get-AzResource -ResourceGroupName $rg.ResourceGroupName -ErrorAction Stop if ($resources.Count -gt 0) { Write-Output " Found $($resources.Count) resources to evaluate" foreach ($resource in $resources) { # Check time limit during resource processing if (-not (Test-ExecutionTimeLimit)) { Write-Output "⏰ Time limit reached during resource processing. Stopping current RG." break } # Only show output for resources we're actually processing if ($resource.ResourceType -in $TargetResourceTypes) { Write-Output " 🔍 Evaluating: $($resource.Name) ($($resource.ResourceType))" } Add-ResourceLockOptimized -Resource $resource -LockName $LockName -Notes $LockNotes -WhatIf $WhatIf -TargetResourceTypes $TargetResourceTypes } } else { Write-Output " No resources found in RG" } } catch { Write-Warning "❌ Failed to get resources from RG $($rg.ResourceGroupName): $($_.Exception.Message)" $batchErrors++ } } # Calculate batch-specific counts $batchProcessed += ($script:ProcessedCount - $beforeProcessed) $batchSuccess += ($script:SuccessCount - $beforeSuccess) $batchSkipped += ($script:SkippedCount - $beforeSkipped) $batchErrors += ($script:ErrorCount - $beforeErrors) # Break if time limit reached if ($script:TimeoutReached) { break } } # Write batch summary Write-BatchSummary -BatchNum $script:BatchNumber -SubscriptionId $subscriptionId -BatchProcessed $batchProcessed -BatchSuccess $batchSuccess -BatchSkipped $batchSkipped -BatchErrors $batchErrors -BatchStartTime $batchStartTime # Break if time limit reached if ($script:TimeoutReached) { break } } } catch { Write-Error "Failed to process subscription $subscriptionId : $($_.Exception.Message)" continue } # Break if time limit reached if ($script:TimeoutReached) { Write-Output "Time limit reached. Stopping all processing." break } } Write-Output "`n🏁 === FINAL EXECUTION SUMMARY ===" Write-Output "🏁 Completed at: $(Get-Date)" $totalElapsed = (Get-Date) - $script:StartTime Write-Output "⏱️ Total execution time: $([math]::Round($totalElapsed.TotalMinutes, 1)) minutes" Write-Output "📊 Batches processed: $($script:BatchNumber)" Write-Output "📋 Total items processed: $($script:ProcessedCount)" Write-Output "✅ Successful locks: $($script:SuccessCount)" Write-Output "⏭️ Skipped (already locked): $($script:SkippedCount)" Write-Output "❌ Errors: $($script:ErrorCount)" if ($script:TimeoutReached) { Write-Output "⚠️ EXECUTION STOPPED DUE TO TIME LIMIT" Write-Output "💡 Consider running again to continue processing remaining items." } if ($WhatIf) { Write-Output "🔍 *** This was a PREVIEW run. No actual changes were made. ***" } Write-Output "🏁 === END SUMMARY ===" } catch { Write-Error "Script execution failed: $($_.Exception.Message)" exit 1 } ## Scheduling the Automation ### Configure Recurring Schedule 1. Set up a schedule in Azure Automation 2. Configure the runbook to execute every 6 hours 3. Ensure proper permissions are assigned to the Automation Account ```powershell $schedule = New-AzAutomationSchedule ` -AutomationAccountName "automatic-resource-locks" ` -Name "ResourceLockSchedule" ` -StartTime "2024-01-01T00:00:00" ` -HourInterval 6 Best Practices Maintain an exclusion list for resources that shouldn’t be locked Implement logging to track lock changes Set up alerts for lock removal events Regular review of locked resources to ensure proper protection Monitoring and Maintenance Regularly check the Automation account’s job history to ensure the runbook executes successfully. Monitor for any failures and adjust the script as needed based on your infrastructure changes. ...

9 min · Me

Securing a Private n8n Instance in Azure with Let’s Encrypt and Managed Identity

This week, I deployed a private n8n automation instance in Azure with a focus on security, auditability, and zero public exposure. Here’s how I solved the HTTPS challenge without storing credentials or opening ports unnecessarily. Problem Statement I needed to: Run n8n privately for internal automations Enable HTTPS for browser access and webhook security Use Let’s Encrypt for free TLS certs Avoid storing Azure credentials on the VM Keep the VM locked down with minimal exposure Azure VM and NSG Setup Deployed Ubuntu VM with n8n running via systemd Configured Azure Network Security Group (NSG) to allow: Port 22 (SSH) and 443 (HTTPS) only Scoped to my static IP Temporarily opened port 80 for Let’s Encrypt HTTP challenge SSL Issue: Nginx Serving Self-Signed Cert Despite running Certbot successfully, openssl s_client revealed: ...

2 min · Me