Retire Your Scheduled-Task Server: Run Multi-Tenant PowerShell in Azure Functions
TL;DR
- Azure Functions run PowerShell as a serverless job, with the Consumption plan giving 1 million free executions a month, so cost is rarely a factor for MSP automation.
- Headless authentication to every customer tenant comes from CyberDrain's secure application model tokens and GUIDs, stored as Function App Application Settings.
- The Function App must run on Powershell Core 7.0 with the platform set to 64-bit, and every module you import has to be declared in Requirements.psd1 first.
- Import modules with the -UseWindowsPowershell switch or the script fails, and MSOnline v1 cmdlets behave differently under Core than the AzureAD v2 module.
- A timer trigger runs jobs on a CRON schedule; an HTTP trigger lets you pass a tenant ID or UPN as a parameter to run a script on demand.
Running PowerShell as a scheduled task on a server you have to patch, monitor, and pay for is a habit worth breaking. Azure Functions move those recurring jobs into a serverless model where you connect to every customer you manage through the secure application model, run a script on a timer or on demand, and let the consumption plan cover the cost. Microsoft's Consumption plan includes 1 million free executions a month (opens in new tab), which for most MSP automation means free. This walkthrough builds a Function App from scratch, wires in familiar modules like Exchange, Partner Center, MSOnline, and AzureAD, and proves the connection by listing every customer in your Partner Center.
What you need before you start
- An active Azure subscription in your directory. The Function App service has to live somewhere, so you need an active subscription to host it.
- Working knowledge of PowerShell. You can follow these steps verbatim, but the real value comes from adding your own scripts. Basic troubleshooting will save you when something inevitably breaks.
- Secure application model tokens and GUIDs. This is the headless authentication that lets one app run scripts across all your tenants. We are not going to recreate the work Kelvin at CyberDrain already published. Follow the CyberDrain secure application model guide (opens in new tab) to generate the tokens and GUIDs. Those secrets are your keys.
Creating the Function App
- Go to Portal.Azure.com and sign in as a Global Admin.
- Search for Function and select Function App.


- Select your active subscription.
- Select an active resource group or create a new one.
- Name the app. This cannot be changed later.
- Select Powershell Core for the Runtime Stack.
- Select the region closest to you.

- Choose to create a new storage account or link to an existing one in your resource group.
- For the plan type, leave the default of Consumption (Serverless). Click Next (Monitoring).

Application Insights is optional. We prefer it off, but the choice is yours. This example leaves it on.
- Review your setup and click Create.

- Deployment takes a few minutes. Once ready, click Go to Resource.

Configuring the runtime so scripts actually run
- From the main page, click Configuration.

- Click General Settings and change the Platform from 32 bit to 64 bit. Click Save at the top of the page.

- Click back on the Application settings tab. This is where you enter the secure application model tokens and GUIDs you generated from the CyberDrain script (opens in new tab).

Add a new application setting for each secret in the script's output.

- When you are done, click Save.

Declaring your module dependencies
- Click the App Files icon on the left nav bar, then select the Requirements.psd1 file from the dropdown.

- This step matters. Requirements.psd1 defines which modules get loaded into the directory so you can import them at runtime. Skip it and the script fails, telling you it does not recognize the modules. The example below adds the PartnerCenter and MSOnline modules. The version value with the
*says keep this current with new versions as they ship. When your modules are added, click Save.

Adding a function and choosing a trigger
- With your dependencies declared, add a new function. Click Functions, then + Add.

- Here you define a trigger. This example uses a timer trigger that runs like a scheduled task. The other trigger worth knowing is the HTTP trigger, which lets you run scripts on demand and pass in variables as parameters. That makes a script dynamic instead of static. A good use is calling a script that takes a customer TenantID and one or more UPNs to act on a single user on demand. For now, click Timer Trigger. The schedule field takes a CRON value. See Microsoft's timer trigger reference (opens in new tab) for the schedule format. This example runs every day at 9:30.

- After you click add, click Code + Test. A boilerplate script appears in the run.ps1 file. Leave the
param($Timer)line, then add the import lines for the modules you declared as dependencies. Click Save after entering them.
Warning: You must add the -UseWindowsPowershell switch. Without it, the script fails.
- With the file saved, click Test/Run. A new window opens on the right. Do not modify anything there. Click Run. The script connects and you see a message telling you the managed dependency is downloading.

Error handling: Some setups error out with a timeout message here. If that happens, run one Import-Module line at a time. Remove one, click Save, Test Run, let it install and import successfully, then add the second line, Save, Test Run, and confirm the second import.
Wiring in your environment variables and proving the connection
- With the modules loaded, reference the Application Settings you added earlier. The names you used as the Key value must match exactly what follows
$ENV:in the script. Also make sure$ENVis followed by a colon, not a period. (A misplaced period here can cost you an hour of confusing errors.)

- Run a simple script first to confirm your environment variables work. This one connects to your own Partner Center and outputs the name of every customer you manage. Paste it into the function, click Save, then Test/Run.
param($Timer)
Import-Module MsOnline -UseWindowsPowershell
Import-Module PartnerCenter -UseWindowsPowershell
$ApplicationId = $ENV:ApplicationID
$ApplicationSecret = $ENV:ApplicationSecret
$secPas = $ApplicationSecret| ConvertTo-SecureString -AsPlainText -Force
$tenantID = $ENV:TenantID
$refreshToken = $ENV:refreshToken
$upn = $ENV:upn
$ExchangeRefreshToken = $ENV:ExchangeRefreshToken
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $secPas)
###Connect to your Own Partner Center to get a list of customers/tenantIDs #########
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID
Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
Write-Host "Found $($customers.Count) customers." -ForegroundColor DarkGreen
foreach ($customer in $customers) {
Write-Host "I found $($customer.Name)" -ForegroundColor Green
}The log should list every customer you manage.

If you get errors, the cause is usually one of these:
- Your ENV variables are wrong. A trailing space in the Application Settings value, a misspelled name, or the wrong
$ENV:syntax. - Permissions were never granted to the app you created with the secure application model script. See Microsoft's guide to granting admin consent (opens in new tab). The app name is whatever you set in the DisplayName parameter.
- Your dependencies never installed. Run the Import-Module lines on their own again. The error looks like this:

Once the commands go through, you are ready to script real work across your customer environments. A few jobs worth running on a schedule:
- Periodically confirming all mailboxes have audit logging enabled.
- Periodically removing licenses from shared mailbox accounts.
- Periodically updating documentation in IT Glue.
What we learned through hours of testing
Azure Functions are powerful, but a few things will save you pain:
- The service runs on Powershell Core 7.0. Scripts you run remotely under version 5.1 may not be compatible. The MSOnline cmdlets are v1; the AzureAD module is v2. Some object properties in MSOnline cannot be extracted under Core, and some cmdlets may not work at all. We recommend writing scripts with the AzureAD module and cmdlets where you can.
- GUI debugging is limited unless you use VS Code. Debug locally in the ISE first, then move the working script into the function to confirm compatibility. Download Powershell 7.0 locally for the best experience.
- Use plenty of Write-Host while testing. Outputting variable values to the log during Test/Run shows you when a variable comes back empty, which pinpoints where the script needs work.
- Connect-AzureAD does not support the delegated admin token the way Connect-MsOnline does. If you run AzureAD cmdlets across customers, connect to each one individually inside a foreach loop.
- The Consumption model caps function runtime at 10 minutes. Customer loops usually finish in a couple of minutes, but with 500+ customers a function can time out. Add stop-resume logic to avoid that.
Frequently asked questions
Do Azure Functions cost money to run scheduled PowerShell?
On the Consumption (Serverless) plan, Azure gives 1 million free function executions per month. MSP automation jobs that loop through customers and finish in a couple of minutes stay well inside the free tier, so cost is rarely a concern.
Why does my module import fail inside the Function?
Two common causes. First, the module was never declared in Requirements.psd1, so it was never downloaded into the directory. Second, you left off the -UseWindowsPowershell switch on Import-Module, which Powershell Core 7.0 needs to load older modules. Add it to every Import-Module line.
Can a single function run against all my customer tenants?
Yes. Using the secure application model, you connect to your own Partner Center to pull the full customer list, then loop through each tenant. Connect-MsolService accepts the delegated admin token directly; Connect-AzureAD does not, so AzureAD cmdlets need a per-customer connection inside the loop.
Automation handles the work. Proof handles the audit.
Scripts close the gaps you remember to script for. CloudCapsule scans every tenant you manage against 250+ controls in 60 seconds and shows you the drift you didn't. Time-stamped evidence for every client.
Try a free scan
Written by
Nick Ross
CEO · Microsoft MVP · Founder, T-Minus 365
Nick is not just a CEO, he's a respected thought leader and influencer in the MSP space. Tens of thousands of MSPs learn through his YouTube channel, T-Minus365. Nick has been honored as a three-time Microsoft MVP for his educational content; his expertise and influence are the backbone of our mission, ensuring that you are in the best hands when it comes to security.
Nick joined Pax8 in 2017, where he would ultimately oversee product management for PSA and Microsoft integrations. Following his tenure at Pax8, Nick has continued to demonstrate his leadership prowess as an executive at various MSPs, culminating in his most recent role at Sourcepass.
Nick holds a Bachelor's Degree in Business Management from Florida State University, as well as a Minor Degree in Entrepreneurship. In his free time, Nick is an avid hiker, reader, and fitness-junkie.


