Here is some key points to deploy a Craft CMS installation on Azure Web App using container images. In this blog we will step you through some of the modifications needed to make the container image run in Azure and the deployment steps to run in an Azure DevOps Pipeline.
CraftCMS have reference material for their docker deployments found here:
GitHub – craftcms/docker: Craft CMS Docker images
Components
The components required are:
- Azure Web App for Linux Containers
- Azure Database for MySQL
- Azure Storage Account
- Azure Front Door with WAF
- Azure Container Registry
Custom Docker Image
To make this work in an Azure Web App we have to do the following additional steps:
- Install OpenSSH & Enable SSH daemon on 2222 at startup
- Set the password for root to “Docker!”
- Install the Azure Database for MySQL root certificates for SSL connections from the Container
We do this in the Dockerfile. We are customizing the NGINX implementation of CraftCMS to allow for the front end to service the HTTP/HTTPS requests from the App Service.
# composer dependencies FROM composer:1 as vendor COPY composer.json composer.json COPY composer.lock composer.lock RUN composer install --ignore-platform-reqs --no-interaction --prefer-dist FROM craftcms/nginx:7.4 # Install OpenSSH and set the password for root to "Docker!". In this example, "apk add" is the install instruction for an Alpine Linux-based image. USER root RUN apk add openssh sudo \ && echo "root:Docker!" | chpasswd # Copy the sshd_config file to the /etc/ directory COPY sshd_config /etc/ssh/ COPY start.sh /etc/start.sh COPY BaltimoreCyberTrustRoot.crt.pem /etc/BaltimoreCyberTrustRoot.crt.pem RUN ssh-keygen -A RUN addgroup sudo RUN adduser www-data sudo RUN echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers # the user is `www-data`, so we copy the files using the user and group USER www-data COPY --chown=www-data:www-data --from=vendor /app/vendor/ /app/vendor/ COPY --chown=www-data:www-data . . EXPOSE 8080 2222 ENTRYPOINT ["sh", "/etc/start.sh"]
The corresponding ‘start.sh’
#!/bin/bash sudo /usr/sbin/sshd & /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisor.conf
Build the Web App
The Azure Web App resource is deployed using a ARM template. Here is a snippet of the template, the key is to have your environment variables defined:
{ "comments": "This is the docker web app running craftcms/custom Docker image", "type": "Microsoft.Web/sites", "name": "[parameters('siteName')]", "apiVersion": "2020-06-01", "location": "[parameters('location')]", "tags": "[parameters('tags')]", "dependsOn": [ "[variables('hostingPlanName')]", "[variables('databaseName')]" ], "properties": { "siteConfig": { "appSettings": [ { "name": "DOCKER_REGISTRY_SERVER_URL", "value": "[reference(variables('registryResourceId'), '2019-05-01').loginServer]" }, { "name": "DOCKER_REGISTRY_SERVER_USERNAME", "value": "[listCredentials(variables('registryResourceId'), '2019-05-01').username]" }, { "name": "DOCKER_REGISTRY_SERVER_PASSWORD", "value": "[listCredentials(variables('registryResourceId'), '2019-05-01').passwords[0].value]" }, { "name": "WEBSITES_ENABLE_APP_SERVICE_STORAGE", "value": "false" }, { "name": "DB_DRIVER", "value": "mysql" }, { "name": "DB_SERVER", "value": "[reference(resourceId('Microsoft.DBforMySQL/servers',variables('serverName'))).fullyQualifiedDomainName]" }, { "name": "DB_PORT", "value": "3306" }, { "name": "DB_DATABASE", "value": "[variables('databaseName')]" }, { "name": "DB_USER", "value": "[variables('databaseUserName')]" }, { "name": "DB_PASSWORD", "value": "[parameters('administratorLoginPassword')]" }, { "name": "DB_SCHEMA", "value": "public" }, { "name": "DB_TABLE_PREFIX", "value": "" }, { "name": "SECURITY_KEY", "value": "[parameters('cmsSecurityKey')]" }, { "name": "WEB_IMAGE", "value": "[parameters('containerImage')]" }, { "name": "WEB_IMAGE_PORTS", "value": "80:8080" } ], "linuxFxVersion": "[variables('linuxFxVersion')]", "scmIpSecurityRestrictions": [ ], "scmIpSecurityRestrictionsUseMain": false, "minTlsVersion": "1.2", "scmMinTlsVersion": "1.0" }, "name": "[parameters('siteName')]", "serverFarmId": "[variables('hostingPlanName')]", "httpsOnly": true }, "resources": [ { "apiVersion": "2020-06-01", "name": "connectionstrings", "type": "config", "dependsOn": [ "[resourceId('Microsoft.Web/sites/', parameters('siteName'))]" ], "tags": "[parameters('tags')]", "properties": { "dbstring": { "value": "[concat('Database=', variables('databaseName'), ';Data Source=', reference(resourceId('Microsoft.DBforMySQL/servers',variables('serverName'))).fullyQualifiedDomainName, ';User Id=', parameters('administratorLogin'),'@', variables('serverName'),';Password=', parameters('administratorLoginPassword'))]", "type": "MySQL" } } } ] },
All other resources should be ARM defaults. No customisation required. Either put them all in a single ARM template or seperate them out on their own. Your choice to be creative.
Build Pipeline
The infrastructure build pipeline looks something like the below:
# Infrastructure pipeline trigger: none pool: vmImage: 'windows-2019' variables: TEMPLATEURI: 'https://storageAccountName.blob.core.windows.net/templates/portal/' CMSSINGLE: 'singleCraftCMSTemplate.json' CMSSINGLEPARAM: 'singleCraftCMSTemplate.parameters.json' CMSFILEREG: 'ContainerRegistry.json' CMSFRONTDOOR: 'frontDoor.json' CMSFILEREGPARAM: 'ContainerRegistry.parameters.json' CMSFRONTDOORPARAM: 'frontDoor.parameters.json' LOCATION: 'Australia East' SUBSCRIPTIONID: '' AZURECLISPID: '' TENANTID: '' RGNAME: '' TOKEN: '' ACS : 'registryName.azurecr.io' resources: repositories: - repository: coderepo type: git name: Project/craftcms stages: - stage: BuildContainerRegistry displayName: BuildRegistry jobs: - job: BuildContainerRegistry displayName: Azure Git Repository pool: vmImage: 'windows-latest' steps: - task: CopyFiles@2 name: copyToBuildHost displayName: 'Copy files to the build host for execution' inputs: Contents: '**' TargetFolder: '$(Build.ArtifactStagingDirectory)' - task: AzureFileCopy@4 inputs: SourcePath: '$(Build.Repository.LocalPath)\CMS\template\*' azureSubscription: '' Destination: 'AzureBlob' storage: '' ContainerName: 'templates' BlobPrefix: portal AdditionalArgumentsForBlobCopy: --recursive=true - task: AzureResourceManagerTemplateDeployment@3 displayName: "Deploy Azure ARM template for Azure Container Registry" inputs: deploymentScope: 'Resource Group' azureResourceManagerConnection: 'azureDeployCLI-SP' subscriptionId: '$(SUBSCRIPTIONID)' action: 'Create Or Update Resource Group' resourceGroupName: '$(RGNAME)' location: '$(LOCATION)' templateLocation: 'URL of the file' csmFileLink: '$(TEMPLATEURI)$(CMSFILEREG)$(TOKEN)' csmParametersFileLink: '$(TEMPLATEURI)$(CMSFILEREGPARAM)$(TOKEN)' deploymentMode: 'Incremental' - task: AzurePowerShell@5 displayName: 'Import the public docker images to the Azure Container Repository' inputs: azureSubscription: 'azureDeployCLI-SP' ScriptType: 'FilePath' ScriptPath: '$(Build.ArtifactStagingDirectory)\CMS\template\dockerImages.ps1' errorActionPreference: 'silentlyContinue' azurePowerShellVersion: 'LatestVersion' - stage: BuildGeneralImg dependsOn: BuildContainerRegistry displayName: BuildImages jobs: - job: BuildCraftCMSImage displayName: General Docker Image pool: vmImage: 'ubuntu-18.04' steps: - checkout: self - checkout: coderepo - task: CopyFiles@2 name: copyToBuildHost displayName: 'Copy files to the build host for execution' inputs: Contents: '**' TargetFolder: '$(Build.ArtifactStagingDirectory)' - task: Docker@2 displayName: Build and push inputs: containerRegistry: '' repository: craftcms command: buildAndPush dockerfile: 'craftcms/Dockerfile' tags: | craftcms latest - stage: Deploy dependsOn: BuildGeneralImg displayName: DeployWebService jobs: - job: displayName: ARM Templates pool: vmImage: 'windows-latest' steps: - checkout: self - checkout: coderepo - task: CopyFiles@2 name: copyToBuildHost displayName: 'Copy files to the build host for execution' inputs: Contents: '**' TargetFolder: '$(Build.ArtifactStagingDirectory)' - task: AzureResourceManagerTemplateDeployment@3 displayName: "Deploy Azure ARM single template for remaining assets" inputs: deploymentScope: 'Resource Group' azureResourceManagerConnection: '' subscriptionId: '$(SUBSCRIPTIONID)' action: 'Create Or Update Resource Group' resourceGroupName: '$(RGNAME)' location: '$(LOCATION)' templateLocation: 'URL of the file' csmFileLink: '$(TEMPLATEURI)$(CMSSINGLE)$(TOKEN)' csmParametersFileLink: '$(TEMPLATEURI)$(CMSSINGLEPARAM)$(TOKEN)' deploymentMode: 'Incremental' - stage: Secure dependsOn: Deploy displayName: DeployFrontDoor jobs: - job: displayName: ARM Templates pool: vmImage: 'windows-latest' steps: - task: CopyFiles@2 name: copyToBuildHost displayName: 'Copy files to the build host for execution' inputs: Contents: '**' TargetFolder: '$(Build.ArtifactStagingDirectory)' - task: AzureResourceManagerTemplateDeployment@3 displayName: "Deploy Azure ARM single template for Front Door" inputs: deploymentScope: 'Resource Group' azureResourceManagerConnection: '' subscriptionId: '$(SUBSCRIPTIONID)' action: 'Create Or Update Resource Group' resourceGroupName: '$(RGNAME)' location: '$(LOCATION)' templateLocation: 'URL of the file' csmFileLink: '$(TEMPLATEURI)$(CMSFRONTDOOR)$(TOKEN)' csmParametersFileLink: '$(TEMPLATEURI)$(CMSFRONTDOORPARAM)$(TOKEN)' deploymentMode: 'Incremental' - task: AzurePowerShell@5 displayName: 'Apply Front Door service tags to Web App ACLs' inputs: azureSubscription: 'azureDeployCLI-SP' ScriptType: 'FilePath' ScriptPath: '$(Build.ArtifactStagingDirectory)\CMS\template\enableFrontDoorOnWebApp.ps1' errorActionPreference: 'silentlyContinue' azurePowerShellVersion: 'LatestVersion'
Enable Front Door with WAF
The pipeline stage DeployFrontDoor has an enableFrontDoorOnWebApp.ps1
$azFrontDoorName = "" $webAppName = "" $resourceGroup = "" Write-Host "INFO: Restrict access to a specific Azure Front Door instance" try{ $afd = Get-AzFrontDoor -Name $azFrontDoorName -ResourceGroupName $resourceGroup } catch{ Write-Host "ERROR: $($_.Exception.Message)" } Write-Host "INFO: Setting the IP ranges defined in the AzureFrontDoor.Backend service tag to the Web App" try{ Add-AzWebAppAccessRestrictionRule -ResourceGroupName $resourceGroup -WebAppName $webAppName -Name "Front Door Restrictions" -Priority 100 -Action Allow -ServiceTag AzureFrontDoor.Backend -HttpHeader @{'x-azure-fdid' = $afd.FrontDoorId}} catch{ Write-Host "ERROR: $($_.Exception.Message)" }
You should now have a CraftCMS web app that is only available through the FrontDoor URL.
Continuous Deployment
There are many ways to deploy updates to your website, an Azure Web App has a beautiful thing called slots that can be used.
# Trigger on commit # Build and push an image to Azure Container Registry # Update Web App Slot trigger: branches: include: - main paths: exclude: - pipelines - README.md batch: true resources: - repo: self pool: vmImage: 'windows-2019' variables: TEMPLATEURI: 'https://storageAccountName.blob.core.windows.net/templates/portal/' LOCATION: 'Australia East' SUBSCRIPTIONID: '' RGNAME: '' TOKEN: '' SASTOKEN: '' TAG: '$(Build.BuildId)' CONTAINERREGISTRY: 'registryName.azurecr.io' IMAGEREPOSITORY: 'craftcms' APPNAME: '' stages: - stage: BuildImg displayName: BuildLatestImage jobs: - job: BuildCraftCMSImage displayName: General Docker Image pool: vmImage: 'ubuntu-18.04' steps: - checkout: self - task: CopyFiles@2 name: copyToBuildHost displayName: 'Copy files to the build host for execution' inputs: Contents: '**' TargetFolder: '$(Build.ArtifactStagingDirectory)' - task: Docker@2 displayName: Build and push inputs: containerRegistry: '' repository: $(IMAGEREPOSITORY) command: buildAndPush dockerfile: 'Dockerfile' tags: | $(IMAGEREPOSITORY) $(TAG) - stage: UpdateApp dependsOn: BuildImg displayName: UpdateTestSlot jobs: - job: displayName: 'Update Web App Slot' pool: vmImage: 'windows-latest' steps: - task: AzureWebAppContainer@1 displayName: 'Update Web App Container Image Reference' inputs: azureSubscription: '' appName: $(APPNAME) containers: $(CONTAINERREGISTRY)/$(IMAGEREPOSITORY):$(TAG) deployToSlotOrASE: true resourceGroupName: $(RGNAME) slotName: test