The Sitecore Publishing Service is the fastest way to publish your Sitecore content. In this post I'll show you how to automate the deployment of the Publishing Service to your Azure environment with Azure ARM templates and integrate it with the Sitecore Azure Quickstart Templates.

Create Web Deployment Package (WDP) for Publishing Service

The Publishing Service runs as its own App Service in Azure. The package that Sitecore distributes for the Publishing Service needs to be modified slightly before deploying to a Web App.

Download the latest version of the Sitecore Publishing Service. Get the 64-bit .NET Core host, not the module.

Convert Publishing Service into WDP

The Publishing Service WDP that is deployed to Azure needs to do the following:

  1. Enable transient error tolerance;
  2. Set the Core, Master, and Web connection strings;
  3. Set the Application Insights Instrumentation Key in appsettings.json;
  4. Update the Sitecore database schemas to support the Publishing Service.

The following PowerShell script will take the Sitecore Publishing Service package that Sitecore provides and convert it into a WDP that handles all of the above:

[CmdletBinding()]
Param(
    [Parameter(Mandatory = $true)]
    [ValidateScript({ (Test-Path $_ -PathType Leaf) -and ($_ -match '\.zip$') })]
    [string]$Path,

    [Parameter(Mandatory=$true)]
    [ValidateScript({ Test-Path $_ -PathType Container -IsValid })]
    [string]$Destination,

    [ValidateScript({ Test-Path $_ -PathType Container -IsValid })]
    [string]$WebRoot = "D:\home\site\wwwroot"
)

$sourcePath = (Resolve-Path $Path).Path
if(!(Test-Path $Destination)) {
    New-Item -ItemType Directory -Path $Destination
}
$destinationPath = (Resolve-Path $Destination).Path

$sourcePackage = Copy-Item -Path $sourcePath -Destination $destinationPath -PassThru
$unzippedPath = [IO.Path]::Combine($destinationPath, $sourcePackage.BaseName)
if (Test-Path $unzippedPath) {
    Remove-Item -Recurse -Force $unzippedPath
}
Expand-Archive -Path $sourcePackage.FullName -Destination $unzippedPath -Force

$appInsightsInstrumentationKey = "ApplicationInsightsInstrumentationKey"
$appSettingsFile = "appsettings.json"
$appSettingsFilePath = "$unzippedPath\$appSettingsFile"
$appSettings = Get-Content -Path $appSettingsFilePath -Raw | ConvertFrom-Json
$appSettings.ApplicationInsights.InstrumentationKey = $appInsightsInstrumentationKey
$appSettings | ConvertTo-Json | Set-Content -Path $appSettingsFilePath

$connectionStringsFile = "config\global\sc.connectionstrings.xml"
[xml]$connectionStrings = `
"<Settings>" +
  "<Sitecore>" +
    "<Publishing>" +
      "<ConnectionStrings>" +
        "<Core>`${Sitecore:Publishing:ConnectionStrings:Core}`</Core>" +
        "<Master>`${Sitecore:Publishing:ConnectionStrings:Master}`</Master>" +
        "<Web>`${Sitecore:Publishing:ConnectionStrings:Web}`</Web>" +
      "</ConnectionStrings>" +
    "</Publishing>" +
  "</Sitecore>" +
"</Settings>"
$connectionStrings.Save("$unzippedPath\$connectionStringsFile")

$manifestFile = [IO.Path]::Combine($destinationPath, "manifest.xml")
[xml]$manifestXml = `
"<sitemanifest>" +
  "<contentPath path=`"$($unzippedPath)`" />" +
  "<runCommand path=`"$($WebRoot)\Sitecore.Framework.Publishing.Host.exe schema upgrade -f`" waitInterval=`"30000`" dontUseCommandExe=`"true`" />" +
"</sitemanifest>"
$manifestXml.Save($manifestFile)

$parametersFile = [IO.Path]::Combine($destinationPath, "parameters.xml")
[xml]$parametersXml = `
"<parameters>" +
  "<parameter tags=`"contentPath`" defaultValue=`"Default Web Site/Content`" description=`"Full site path where you would like to install your application (i.e., Default Web Site/Content)`" name=`"Application Path`">" +
    "<parameterEntry type=`"ProviderPath`" scope=`"contentPath`" match=`"$([Regex]::Escape($unzippedPath))`" />" +
  "</parameter>" +
  "<parameter name=`"Application Insights Instrumentation Key`" description=`"Sitecore Application Insights Instrumentation Key`" tags=`"Hidden,NoStore`">" +
    "<parameterEntry kind=`"TextFile`" scope=`"$([Regex]::Escape($appSettingsFile))`" match=`"$($appInsightsInstrumentationKey)`" />" +
  "</parameter>" +
  "<parameter name=`"Core Admin Connection String`" description=`"Connection string to enter into config`" tags=`"SQL, Hidden,NoStore`">" +
    "<parameterEntry kind=`"XmlFile`" scope=`"$([Regex]::Escape($connectionStringsFile))`" match=`"/Settings/Sitecore/Publishing/ConnectionStrings/Core/text()`" />" +
  "</parameter>" +
  "<parameter name=`"Master Admin Connection String`" description=`"Connection string to enter into config`" tags=`"SQL, Hidden,NoStore`">" +
    "<parameterEntry kind=`"XmlFile`" scope=`"$([Regex]::Escape($connectionStringsFile))`" match=`"/Settings/Sitecore/Publishing/ConnectionStrings/Master/text()`" />" +
  "</parameter>" +
  "<parameter name=`"Web Admin Connection String`" description=`"Connection string to enter into config`" tags=`"SQL, Hidden,NoStore`">" +
    "<parameterEntry kind=`"XmlFile`" scope=`"$([Regex]::Escape($connectionStringsFile))`" match=`"/Settings/Sitecore/Publishing/ConnectionStrings/Web/text()`" />" +
  "</parameter>" +
"</parameters>"
$parametersXml.Save($parametersFile)

$outputFile = [IO.Path]::Combine($destinationPath, "$($sourcePackage.BaseName).wdp.zip")
$msDeploy = [IO.Path]::Combine($env:ProgramFiles, 'IIS', 'Microsoft Web Deploy V3', 'msdeploy.exe')
$packageCommand = "& '$msDeploy' --%" +
    " -verb:sync" +
    " -source:manifest='$manifestFile'" +
    " -dest:package='$outputFile'" +
    " -declareParamFile=$parametersFile" +
    " -replace:match='.*sc\.publishing\.sqlazure\.connections\.xml\.example',replace='sc.publishing.sqlazure.connections.xml'" +
    " -replace:match='$([Regex]::Escape($unzippedPath))',replace='Website'"
Invoke-Expression $packageCommand

Remove-Item $sourcePackage -Force
Remove-Item $manifestFile -Force
Remove-Item $parametersFile -Force
Remove-Item $unzippedPath -Recurse -Force

Save the above script to disk as ConvertTo-PublishingServiceWdp.ps1 and run it:

.\ConvertTo-PublishingServiceWdp.ps1 -Path "C:\Sitecore\Packages\Sitecore Publishing Service  3.1.1 rev. 180807-x64.zip" -Destination "C:\output"

After the script runs, you'll have a WDP called Sitecore Publishing Service 3.1.1 rev. 180807-x64.wdp.zip at the Destination that is ready for deploy to Azure.

Note: As a bonus, you can use this WDP with the Sitecore Installation Framework to automate setup of the Publishing Service locally, too.

Upload the Publishing Service WDP to a Public URL

Upload the WDP to a public URL, preferrably an Azure Storage Account in the same region where you'll deploy your Sitecore environment. Make note of the URL as you'll need it later.

Create WDP for Publishing Module

The Publishing Service requires that a module be installed on your CM and CD Web Apps. The Publishing Service Module is only distributed as a Sitecore package, but the Sitecore Azure Toolkit makes it easy to convert any Sitecore package into a WDP.

Download the latest version of the Sitecore Azure Toolkit (SAT) and the Publishing Service Module.

Convert Publishing Module into WDP

Extract the contents of SAT to some place with easy access. For this post I'll extract to C:\Sitecore\SAT.

The following PowerShell script will convert the Publishing Service Module Sitecore package into a WDP:

[CmdletBinding()]
Param(
    [Parameter(Mandatory=$true)]
    [ValidateScript({ Test-Path $_ -PathType Container -IsValid })]
    [string]$SitecoreAzureToolkitRoot,

    [Parameter(Mandatory = $true)]
    [ValidateScript({ (Test-Path $_ -PathType Leaf) -and ($_ -match '\.zip$') })]
    [string]$Path,

    [Parameter(Mandatory=$true)]
    [ValidateScript({ Test-Path $_ -PathType Container -IsValid })]
    [string]$Destination,

    [switch]$GenerateCdPackage
)
Import-Module "$SitecoreAzureToolkitRoot\tools\Sitecore.Cloud.Cmdlets.dll"
Add-Type -Path "$SitecoreAzureToolkitRoot\tools\DotNetZip.dll"

$sourcePath = (Resolve-Path $Path).Path
if(!(Test-Path $Destination)) {
    New-Item -ItemType Directory -Path $Destination
}
$destinationPath = (Resolve-Path $Destination).Path

$scWdpPath = ConvertTo-SCModuleWebDeployPackage `
    -Path "$sourcePath" `
    -Destination "$destinationPath" `
    -Verbose `
    -Force

try {
    # Add Publishing Service URL parameter into WDP
    $publishingServiceConfig = "Sitecore.Publishing.Service.config"
    [xml]$publishingParameter = `
    "<parameter name=`"Publishing Service URL`" description=`"URL to the Publishing Service Site`" tags=`"Hidden,NoStore`">" +
      "<parameterEntry kind=`"XmlFile`" scope=`"$([Regex]::Escape($publishingServiceConfig))`" match=`"/configuration/sitecore/settings/setting[@name='PublishingService.UrlRoot']/@value`" />" +
    "</parameter>"

    $zip = [Ionic.Zip.ZipFile]::new($scWdpPath)
    $parametersFile = $zip.Entries | Where-Object { $_.FileName -eq "parameters.xml" }
    ($parametersXml = New-Object System.Xml.XmlDocument).Load($parametersFile.OpenReader())
    $parametersXml.parameters.AppendChild($parametersXml.ImportNode($publishingParameter.parameter, $true)) | Out-Null

    $parametersStream = New-Object System.IO.MemoryStream
    $parametersXml.Save($parametersStream)
    $parametersStream.Seek(0, [System.IO.SeekOrigin]::Begin) | Out-Null
    $zip.UpdateEntry($parametersFile.FileName, $parametersStream) | Out-Null
    $zip.Save()
} finally {
    if ($zip) { $zip.Dispose() }
    if ($parametersStream) { $parametersStream.Dispose() }
}

if (!$GenerateCdPackage) { return }
Remove-SCDatabaseOperations -Path "$scWdpPath" -Destination "$destinationPath" -Verbose -Force

This script uses SAT to convert the Publishing Service Module into a WDP, adds a Publishing Service URL parameter that will set the PublishingService.UrlRoot setting in Sitecore.Publishing.Service.config, and optionally creates a package for Content Delivery Web Apps with no database operations.

You may notice that I'm using the DotNetZip library from SAT to add parameters.xml into the WDP. For reasons unknown to me, files added to a WDP with Expand-Archive/Compress-Archive/System.IO.Compression.ZipFile are skipped by MSDeploy when the package is deployed[1], but files added with DotNetZip are not. If you need to add/remove/edit files in a WDP, DotNetZip is the way to go. In fact, it's in the SAT for a reason--Sitecore also uses it to manipulate WDPs.

Save the above script to disk as ConvertTo-PublishingServiceModuleWdp.ps1 and run it like so:

 .\ConvertTo-PublishingServiceModuleWdp.ps1 -SitecoreAzureToolkitRoot "C:\Sitecore\SAT" -Path "C:\Sitecore\Packages\Sitecore Publishing Module 3.1.1 rev. 180807.zip" -Destination "C:\output"

After this you'll have a WDP called Sitecore Publishing Module 3.1.1 rev. 180807.scwdp.zip at the Destination that you can deploy to Azure. If you are deploying to an XP Scaled environment, pass the GenerateCdPackage switch to create a CD-specific version of the WDP as well.

Optional: If you need the Content Availability feature for Azure Search or Solr, check out my Enable-ContentAvailability script. Run this against the WDP(s) generated above and it will enable the Content Availability feature. If you're using Azure Search, pass the path of the Sitecore.Publishing.Service.ContentAvailability.azure.config config that I created to the AzureSearchConfig parameter and you'll get Azure Search support (which is strangely missing from out of the box)! Do not enable this feature without understanding the impacts it will have on your application.

⚠️WARNING⚠️: If your release process clears your web roots on each deploy (it should!), make sure that you have a rule to exclude Sitecore.Publishing.Service.config from the clearing process as it will contain the Publishing Service URL Sitecore needs to communicate with the Publishing Service.

Upload the Publishing Module WDP to a Public URL

Upload the WDP to a public URL, preferrably an Azure Storage Account in the same region where you'll deploy your Sitecore environment. Make note of the URL as you'll need it in the next section.

💪 ARM Time 💪

Grab the ARM Templates

I've created a set of ARM templates following the architecture of the Sitecore Azure Quickstart Templates that you can grab from GitHub here: https://github.com/coreyasmith/sitecore-publishing-service-azure-templates

There are three nested templates:

  1. infrastructure.json
  2. application.json
  3. module.json

infrastructure.json sets up the App Service and App Service Plan for the Publishing Service in Azure. application.json deploys the Publishing Service WDP to the App Service created in infrastructure.json. The WDP configures the connection strings, application insights, and provisions the SQL databases to support the publishing service. Finally, module.json installs the Publishing Service module WDP on your CM and sets the Publishing Service URL setting so the CM can connect to the Publishing Service.

Download this repository and host the ARM templates alongside your other Sitecore templates.

🚨DANGER:🚨 DO NOT link to the ARM templates in my GitHub repository from your ARM templates. I may take the repo down at some point in the future, or I may make breaking changes to the templates. If you ignore this warning and your infrastructure breaks, you're 100% on your own and I will not provide support.

Integrate into azure.parameters.json

Open up azure.parameters.json for your ARM templates and add the following underneath the last parameter, inside of the parameters object:

"modules": {
  "value": {
    "items": [
      {
        "name": "ps",
        "templateLink": "https://yourdomain.com/ps-arm/<version>/azuredeploy.json",
        "parameters": {
          "templateLinkAccessToken": "Optional access token for the template if stored in Azure storage. Otherwise should be empty string.",
          "psMsDeployPackageUrl": "<URL of the WDP file Sitecore Publishing Service *-x64.wdp.zip>",
          "psModuleMsDeployPackageUrl": "<URL of the WDP file Sitecore Publishing Module *.scwdp.zip>"
        }
      }
    ]
  }
}

If you are deploying the Scaled templates, you'll need to pass cmPsModuleMsDeployPackageUrl and cdPsModuleMsDeployPackageUrl with the appropriate URLs instead of psModuleMsDeployPackageUrl.

The WDPs in these templates do not have any cargo payloads, so you don't need to add the Bootloader to your modules section for this template. If you already have the Bootloader in place for other modules, I'd suggest leaving the Bootloader last in your modules (i.e., insert ps before it).

Run the Deployment

Now you're ready to run the deployment. After it finishes, you'll have a new instance of Sitecore with the Publishing Service set up and ready to go! A full environment rebuild from scratch takes roughly 40-50 minutes for me with the Single templates. The Publishing Service portion of the deploy is fairly quick--it only takes 2-3 minutes.

ps-deploy

Conclusion

At this point you should have the Publishing Service set up in Azure, and now it's easily deployable to other environments. Combine this guide with my guide on deploying JavaScript Services with ARM templates to build out a super-modern Sitecore environment in Azure!

Let me know your thoughts in the comments.


  1. Thanks to Sitecore MVP Michael "PowerShell" West for pointing out this article where Microsoft acknowledges this known MSDeploy incompatibility: https://blogs.msdn.microsoft.com/waws/2018/05/23/investigating-issues-using-web-deploy-for-azure-app-service/ ↩︎