Serving SPAs with PHP Blessed Images
This post provides information on how to serve Single Page Applications(SPAs) with Azure App Service on Linux PHP “Blessed” Images.
Getting started
A question that may be asked is why would one need to do this?
PHP for Linux App Service Images use Apache (PHP 7.4) and NGINX (PHP 8.0), therefor these Web Servers could be utilized to do things on the server side while still serving static content, such as redirects, rewrites, or other various Web Server configuration, that cannot be done using a regular Node for Linux App Service “Blessed” Image.
This is because by default neither of these Web Servers run in the container for Node App Service “Blessed” Images and it is up to the developer to bring the server of their choosing (eg, plain Node itself or any number of framework/libraries that can run a ‘live’ node server).
Another possible reason is not wanting to do any of the above programatically or place another device or product in between the client and the application for the same metioned functionality.
Create a Linux PHP App Service
We can get started on this by creating a PHP Linux App Service.
Create a Single Page Application
For the SPA itself, use a quickstart for any of the following
Important information on deployments:
Depending on how deployment is done, you may run into errors if you attempt to deploy a typical SPA directly to a Linux PHP App Service without any additional configuration. This is because of the logic here in what Oryx (the build agent for certain deployments) looks for.
PHP 7.4 (Apache)
NOTE: PHP 7.4 is end-of-life. Consider upgrader to at least PHP 8.2.
This will target deployment and configuration for deploying SPA’s to a PHP 7.4 Linux App Service which utilizes Apache as its Web Server.
Configure a startup script to override Apache
If using this approach for Apache specific configuration, then a startup script will need to be added.
- SSH into the Linux PHP App Service you created and run
cp /etc/apache2/apache2.conf /home
. - Copy this down with an FTP client of your choice.
- Make your changes in
apache2.conf
. Upload this back to/home
. In this example, we’ll assume to be removing theServer
header. Inapache2.conf
the following is added:
# If the contents of this folder is directly in root then this doesn't have to change
...
..
# Change this to point to your production folder if this is deployed
DocumentRoot /home/site/wwwroot/build
...
..
<IfModule security2_module>
SecRuleEngine on
ServerTokens Min
SecServerSignature " "
</IfModule>
...
..
- Create your custom startup script, which must be a
.sh
file. We’ll add the following:
#!/bin/bash
echo "Installing mod_security.."
apt-get update -yy && \
apt-get install libapache2-mod-security2 -yy
APACHE_CONF=/home/apache2.conf
if [ -f "$APACHE_CONF" ]; then
echo "Removing Apache Server header.."
cp "$APACHE_CONF" /etc/apache2/apache2.conf
else
echo "File does not exist, skipping cp."
fi
NOTE:
apache2 -D FOREGROUND
is automatically ran and doesn’t need to be added into the startup script.
- Upload the custom startup script to
/home
as well and update the Azure Portal for the PHP Linux App Service under ‘Configuration’ -> ‘General Settings’:
- Click ‘Save’. We can now use this for the below deployment methods.
NOTE: Apache’s
DocumentRoot
is set to/home/site/wwwroot
by default. If needing to point to a specific production folder then update this as needed within the customapache2.conf
.
FTP
Before deploying, generate the production build folder for your SPA locally. Depending on what you’re using (React, Angular, Vue), this is generally done through either yarn build
or npm run build
, depending on your package manager.
React will output a folder named /build
, Angular will have /dist
and Vue will also have /dist
. If you’re unfamiliar with what your production build folder should look like for your framework/library - please consult its documentation.
-
Go to the Azure Portal for the PHP App Service you created. Go to Deployment Center and choose the FTPS credentials tab.
- Using an FTP client of your choosing, connect using the credentials in the portal, as seen above.
-
Copy the contents within your production build folder to
/home/site/wwwroot
with your FTP client session. Make sure this is not the build folder itself. The directory should look something like the below. The important takeaway is thatindex.html
is withinwwwroot
.NOTE: Apaches
DocumentRoot
is set to/home/site/wwwroot
- Restart the site. Shortly after your SPA should be viewable.
- If wanting to do further Apache configuration along with serving your static content please refer this the above section
NOTE: If you’d rather upload the entire production folder (eg., build, dist) then Apaches
DocumentRoot
will need to be updated in the custom startup script to point to/home/site/wwwroot/<your_prod_folder>
Local Git
Before doing this an App Setting with the name SCM_DO_BUILD_DURING_DEPLOYMENT
set to false
needs to be added. If this isn’t done, deployment will fail since no PHP project structure will be detected.
Local Git can be deployed by updating Apache’s DocumentRoot to point to /home/site/wwwroot/build
.
- Set your deployment method to Local Git in the portal for the App Service you’re deploying to under Deployment Center.
- Change
DocumentRoot
to/home/site/wwwroot/build
,/home/site/wwwroot/dist
, or your revelant build folder using the above section. - Make sure to remove
/build
,/dist
or your applicable production folder name from.gitignore
. Runyarn build
ornpm run build
to build your production folder locally. -
Navigate to your site root and run the below commands:
git add . git commit -m "initial commit" git remote add azure https://<sitename>.scm.azurewebsites.net:443/<sitename>.git git push azure master
-
Something like the below should be shown in your terminal:
remote: Deploy Async remote: Updating branch 'master'. remote: Updating submodules. remote: Preparing deployment for commit id '5123b996c5'. remote: Generating deployment script. remote: Generating deployment script for Web Site remote: Generated deployment script files remote: Running deployment command... remote: Handling Basic Web Site deployment. remote: Kudu sync from: '/home/site/repository' to: '/home/site/wwwroot' remote: Copying file: '.gitignore' remote: Copying file: 'apache2.conf' remote: Copying file: 'package.json' remote: Copying file: 'startup.sh' remote: Copying file: 'yarn.lock' remote: Deleting file: 'hostingstart.html' remote: Ignoring: .git remote: Copying file: 'build/asset-manifest.json' remote: Copying file: 'build/favicon.ico' remote: Copying file: 'build/index.html' remote: Copying file: 'build/logo192.png' remote: Copying file: 'build/logo512.png' remote: Copying file: 'build/manifest.json' remote: Copying file: 'build/robots.txt' remote: Copying file: 'build/static/css/main.a617e044.chunk.css' remote: Copying file: 'build/static/css/main.a617e044.chunk.css.map' remote: Copying file: 'build/static/js/2.637c3219.chunk.js' remote: Copying file: 'build/static/js/2.637c3219.chunk.js.LICENSE.txt' remote: Copying file: 'build/static/js/2.637c3219.chunk.js.map' remote: Copying file: 'build/static/js/3.1d7fd63a.chunk.js' remote: Copying file: 'build/static/js/3.1d7fd63a.chunk.js.map' remote: Copying file: 'build/static/js/main.bf5f35fa.chunk.js' remote: Copying file: 'build/static/js/main.bf5f35fa.chunk.js.map' remote: Copying file: 'build/static/js/runtime-main.165ea00d.js' remote: Copying file: 'build/static/js/runtime-main.165ea00d.js.map' remote: Copying file: 'build/static/media/logo.6ce24c58.svg' remote: Copying file: 'public/favicon.ico' remote: Copying file: 'public/index.html' remote: Copying file: 'public/logo192.png' remote: Copying file: 'public/logo512.png' remote: Copying file: 'public/manifest.json' remote: Copying file: 'public/robots.txt' remote: Copying file: 'src/App.css' remote: Copying file: 'src/App.js' remote: Copying file: 'src/App.test.js' remote: Copying file: 'src/index.css' remote: Copying file: 'src/index.js' remote: Copying file: 'src/logo.svg' remote: Copying file: 'src/reportWebVitals.js' remote: Copying file: 'src/setupTests.js' remote: Finished successfully. remote: Running post deployment command(s)... remote: Triggering recycle (preview mode disabled). remote: Deployment successful.
- Now navigating to the site with the custom startup script for Apache should show the application content.
GitHub Actions
We can deploy with GitHub Actions however this won’t be possible unless we do some changes.
-
Create a PHP 7.4 Linux App Service. Update Apache’s
DocumentRoot
to point to your production folder. Follow these steps on setting up the custom startup script. -
Start by going to the portal for the App Service and choose Deployment Center:
-
Choose GitHub from the dropdown.
-
Fill in the required fields below - after this you can preview the file that will be commited.
IMPORTANT: Doing this first will commit a Actions file geared towards PHP. We’ll need to change this to build our SPA which will be seen below. You can commit this to your repository now, and change this later, or manually create a GitHub Actions file when then can be found when going through the above flow.
We’ll need to change the GitHub Actions .yaml file to something like below:
name: Build and deploy PHP app to Azure Web App - yourappname
on:
push:
branches:
- main
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Node.js version
uses: actions/setup-node@v1
with:
node-version: '18.x'
- name: yarn install, build
run: |
yarn install
yarn run build
- name: Zip artifact for deployment
run: zip release.zip ./* -r
- name: Upload artifact for deployment job
uses: actions/upload-artifact@v2
with:
name: node-app
path: release.zip
deploy:
runs-on: ubuntu-latest
needs: build
environment:
name: 'production'
url: $
steps:
- name: Download artifact from build job
uses: actions/download-artifact@v2
with:
name: node-app
- name: 'Deploy to Azure Web App'
uses: azure/webapps-deploy@v2
id: deploy-to-webapp
with:
app-name: 'yourappname'
slot-name: 'production'
publish-profile: $
package: release.zip
NOTE: The zipping of files between stages can help improve build time since the size of
node_modules
can impact performance.
The above examples we’re build our SPA for production, which will output a build
, dist
, or other production folder.
We can then use the custom startup script mentioned earlier to point Apache’s DocumentRoot
to that folder - eg., /home/site/wwwroot/dist
.
Although this file is meant for our SPA - ultimately the App Service running is still a PHP based runtime, just using Apache to serve the static contents.
Azure DevOps
Azure DevOps can be used to build our SPA much like the above with GitHub Actions where the App Service itself is specified as a PHP runtime while the build(pipeline) is node specific.
- Create a PHP 7.4 Linux App Service. Update Apache’s
DocumentRoot
to point to your production folder. Follow these steps on setting up the custom startup script. - Create a new DevOps project then go to
Pipelines
and selectCreate Pipeline
. See this for more details on creation. - Select your code repository.
- Select the
Node.js Express Web App to Linux on Azure
template.
This will generate a template like the below:
# Node.js Express Web App to Linux on Azure
# Build a Node.js Express app and deploy it to Azure as a Linux web app.
# Add steps that analyze code, save build artifacts, deploy, and more:
# https://docs.microsoft.com/azure/devops/pipelines/languages/javascript
trigger:
- main
variables:
# Azure Resource Manager connection created during pipeline creation
azureSubscription: '00000000-0000-0000-0000-000000000000'
# Web app name
webAppName: 'yourappname'
# Environment name
environmentName: 'yourappname'
# Agent VM image name
vmImageName: 'ubuntu-latest'
stages:
- stage: Build
displayName: Build stage
jobs:
- job: Build
displayName: Build
pool:
vmImage: $(vmImageName)
steps:
- task: NodeTool@0
inputs:
versionSpec: '10.x'
displayName: 'Install Node.js'
- script: |
npm install
npm run build --if-present
npm run test --if-present
displayName: 'npm install, build and test'
- task: ArchiveFiles@2
displayName: 'Archive files'
inputs:
rootFolderOrFile: '$(System.DefaultWorkingDirectory)'
includeRootFolder: false
archiveType: zip
archiveFile: $(Build.ArtifactStagingDirectory)/$(Build.BuildId).zip
replaceExistingArchive: true
- upload: $(Build.ArtifactStagingDirectory)/$(Build.BuildId).zip
artifact: drop
- stage: Deploy
displayName: Deploy stage
dependsOn: Build
condition: succeeded()
jobs:
- deployment: Deploy
displayName: Deploy
environment: $(environmentName)
pool:
vmImage: $(vmImageName)
strategy:
runOnce:
deploy:
steps:
- task: AzureWebApp@1
displayName: 'Azure Web App Deploy: yourappname'
inputs:
azureSubscription: $(azureSubscription)
appType: webAppLinux
appName: $(webAppName)
runtimeStack: 'NODE|10.10'
package: $(Pipeline.Workspace)/drop/$(Build.BuildId).zip
startUpCommand: 'npm run start'
We need to change this to the following:
# Node.js Express Web App to Linux on Azure
# Build a Node.js Express app and deploy it to Azure as a Linux web app.
# Add steps that analyze code, save build artifacts, deploy, and more:
# https://docs.microsoft.com/azure/devops/pipelines/languages/javascript
trigger:
- main
variables:
# Azure Resource Manager connection created during pipeline creation
azureSubscription: '000000-0000-0000-0000-000000000000'
# Web app name
webAppName: 'yourappname'
# Environment name
environmentName: 'yourappname'
# Agent VM image name
vmImageName: 'ubuntu-latest'
stages:
- stage: Build
displayName: Build stage
jobs:
- job: Build
displayName: Build
pool:
vmImage: $(vmImageName)
steps:
- task: NodeTool@0
inputs:
// Change this to your desired Node version
// In the format of 'major.x'
// '.x' denotes latest minor of the specified major
versionSpec: '18.x'
displayName: 'Install Node.js'
// This can be either npm or yarn
// Change this as desired
- script: |
yarn install
yarn build --if-present
displayName: 'yarn install and build'
- task: ArchiveFiles@2
displayName: 'Archive files'
inputs:
rootFolderOrFile: '$(System.DefaultWorkingDirectory)'
includeRootFolder: false
archiveType: zip
archiveFile: $(Build.ArtifactStagingDirectory)/$(Build.BuildId).zip
replaceExistingArchive: true
- upload: $(Build.ArtifactStagingDirectory)/$(Build.BuildId).zip
artifact: drop
- stage: Deploy
displayName: Deploy stage
dependsOn: Build
condition: succeeded()
jobs:
- deployment: Deploy
displayName: Deploy
environment: $(environmentName)
pool:
vmImage: $(vmImageName)
strategy:
runOnce:
deploy:
steps:
- task: AzureWebApp@1
displayName: 'Azure Web App Deploy: yourappname'
inputs:
// Note that we remove 'startupCommand'
azureSubscription: $(azureSubscription)
appType: webAppLinux
appName: $(webAppName)
runtimeStack: 'PHP|7.4'
package: $(Pipeline.Workspace)/drop/$(Build.BuildId).zip
After changing the above - in addition to the custom startup script - the site should now be viewable after deployment
PHP 8.0 (NGINX)
This will target deployment and configuration for deploying SPA’s to a PHP 8.0 Linux App Service which utilizes NGINX as its Web Server.
Configure a startup script to override NGINX
If using this approach for NGINX specific configuration, then a startup script will need to be added.
- SSH into the Linux PHP App Service you created and run
cp /etc/nginx/sites-available/default /home
. - Copy this down with an FTP client of your choice.
- Make your changes in
default
. Upload this back to/home
.
Our new custom default
conf file should look like the below:
server {
#proxy_cache cache;
#proxy_cache_valid 200 1s;
listen 8080;
listen [::]:8080;
# This is the new change we introduced
root /home/site/wwwroot/build;
index index.php index.html index.htm;
server_name example.com www.example.com;
location / {
index index.php index.html index.htm hostingstart.html;
try_files $uri /index.html =404;
}
....
other code from the default file
...
..
}
NOTE: The try_files $uri /index.html =404;
above helps redirect requests back to index.html
for client-side routing to take over (if enabled). For example, if using react-router-dom
or others. This should also handle proper routing with query strings.
Our custom startup script will look like this:
#!/bin/bash
echo "Copying custom default over to /etc/nginx/sites-available/default"
NGINX_CONF=/home/default
if [ -f "$NGINX_CONF" ]; then
cp "$NGINX_CONF" /etc/nginx/sites-available/default
service nginx reload
else
echo "File does not exist, skipping cp."
fi
Upload this to the application and portal with the steps mentioned earlier.
FTP
- Create a PHP 8 Linux App Service. Apply the custom NGINX startup script above if deciding to upload the entire production build folder.
- The same steps mentioned earlier apply here.
Local Git
- Create a PHP 8 Linux App Service. Apply the custom NGINX startup script above.
- The same steps mentioned earlier apply here.
GitHub Actions
- Create a PHP 8 Linux App Service. Apply the custom NGINX startup script above.
- The same steps mentioned earlier apply here.
Azure DevOps
- Create a PHP 8 Linux App Service.
- There is only one change that needs to be made here, which is to the
runtimeStack
option in the.yaml
file. Set this to:runtimeStack: PHP|8.2
- The rest of the steps mentioned earlier still apply here.
Troubleshooting
Error: Couldn’t detect a version for the platform ‘php’ in the repo.
Scenario:
This will happen if deploying with Local Git and not setting the App Setting SCM_DO_BUILD_DURING_DEPLOYMENT
to false
.
Resolution:
Set SCM_DO_BUILD_DURING_DEPLOYMENT
to false
.