Using Azure Pipelines to build and deploy a Next.js app to Azure app services
The popular choices (admittedly this is just based on my own experience and opinions, but I don't think many would argue otherwise!) for hosting a Next.js app are Vercel and Netlify - the speed and ease at which you can get your app deployed on to those platforms really is impressive - but you may find yourself in a situation where you need to host your app in Azure, for example:
You may have existing projects, infrastructure or pieces of your system architecture in Azure and it would simplify things for you to host your Next.js app on the same platform
The pricing structure of Vercel and Netlify may not scale well for your project and organisation, and Azure would be better suited
You just really love Azure (and that's OK)
The project I was working on fell into the first kind of situation and I couldn't find a lot of discussion or guidance for hosting a Next.js app in Azure that met my requirements:
Needs to support SSR and SSG scenarios including Automatic Static Optimization, and Incremental Static Regeneration
Static assets (such as those produced by
next build
andpublic
assets) need to be cached by a CDNServer and client performance and failures need to be tracked in Application Insights
Build and deployment automation supporting multiple target environments via Azure Pipelines
I did find some good articles and code samples when searching around that looked helpful, but I knew that I would need to add to or adapt what I had learned to get everything to fit as I wanted it.
I ended up creating a sample app that I could use use as an example of a working solution for hosting a Next application in Azure, and I wanted to record and share what I had learned here in case it may help me or others in future.
In this article I will not be trying to convince anyone that Azure should also be a popular choice for hosting a Next app, but I do think Azure is a good alternative choice for hosting. I will describe how I have developed the sample application to meet my requirements, and the issues and solutions found along the way.
As mentioned at the top of this article the sample app is available on GitHub. If you're not interested in the write up and just want to get your hands on a solution then that's a good place to go.
Finally, whilst I am happy with where I ended up and the sample app, I am not saying that this is the best or only way to approach this so if you feel there is anything that could be improved then please open an issue on the sample app repo.
With all that said, let's get into it...
What are we deploying?
The first thing I wanted to know was what exactly did I need to deploy to get my Next.js app running in Azure app services. The official docs do a good job of describing and selling you on Vercel (which is fair enough), but are not particularly forthcoming with details of how to host your Next.js app elsewhere. They do provide some details though:
Next.js can be deployed to any hosting provider that supports Node.js. This is the approach you should take if you’re using a custom server.
Azure app services can host Node apps so I now knew that I would need to deploy a custom server.js
. The docs also stated that:
The
next build
command produces a production application in the.next
folder so I will also need to deploy thatI need to make a small tweak to my
package.json
to use my custom server when running thestart
script so I will also need to deploy that also
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "node server.js"
}
As mentioned earlier I did find a couple of helpful resources (this article in particular by Parveen Singh) so I knew from those that I would also need a web.config file because Azure app services manages access to Node via an IIS module called iisnode, and IIS needs a web.config
.
I couldn't find any resources that listed definitively what else (if anything) needed to be deployed - some articles seemed to suggest that I needed to deploy all of my source code (pages, components etc.), but I didn't believe that was true because I have already got my production application built and bundled in the .next
folder.
At this point I decided to quickly create an app service via the Azure Portal and deploy to it via FTP just to do a bit of trial and error and get to a point where I had a functioning Next.js app with as few files as possible, and that ended up being:
.next/**
node_modules/**
public/**
next.config.js
package.json
server.js
web.config
A little detour through node_modules
As mentioned, the next build
command produces a production application in the .next
folder so you may be wondering why we need to also deploy node_modules
. The reason is that the custom server that we are deploying needs access to its production dependencies.
node_modules
is typically hundreds or thousands of files and folders and deploying it via FTP is a non-starter, but I needed to get it deployed so that I could run my app. I had already deployed my package.json
file so I knew I could run npm install
on the app service via the Kudu console. Doing that got my node_modules
installed and in place for my custom server.
This was fine during my little trial and error experiment, but I knew that I would also need a way to deal with this when deploying my app with Azure Pipelines. I was also using yarn
locally and wanted to use my yarn.lock
file to ensure the dependencies installed were consistent across environments.
There were two problems to overcome here:
Azure app services have
npm
installed by default, but notyarn
I would need to be able to trigger execution of
yarn
at the appropriate point in my automated release pipeline
Thankfully there was a solution to both of these problems in this kudu-yarn repo on GitHub. The repo contains a custom deployment script that installs yarn
globally via npm
and then executes yarn
post-deployment.
This meant that I needed to add three more files to my deployment package - yarn.lock
, .deployment
and deploy.cmd
, but I wouldn't have to deploy node_modules
, which is a win!
Supporting a CDN
I wanted to deliver all static resources (any file that doesn't change after it has been built) via an Azure CDN and have those resources use an agressive cache policy that would be busted by a new release. This is something that is largely taken care of for you when hosting with Vercel, but is something I would need to explcitly cater for in Azure by making some modifications to my application.
Next has a config setting called assetPrefix, which when set to a CDN endpoint URL will:
... automatically use your asset prefix for the JavaScript and CSS files it loads from the
/_next/
path (.next/static/
folder).
I can set the assetPrefix
using an environment variable, which I can expose in both my pipeline and app service environments (via an application setting) so this is a pretty simple modification to my next.config.js
file.
This is a great start, but the docs also state that it won't add the prefix to requests for paths under /_next/data/
(no problem as these are not static resources); or requests for files in your public
folder (a problem for me as these are static resources).
Regarding public
files the docs state:
... if you want to serve those assets over a CDN, you'll have to introduce the prefix yourself
There's a few ways I could do that, but the approach I settled on was:
Set a build id variable in my pipeline
I ended up using the source Git commit id
Expose the build id variable via an environment variable that I can use in my app
Azure Pipelines exposes variables as environment variables by default
Write a
getCdnUrl
function in my app that takes a path and produces an absolute URL that combines our CDN endpoint URL (assetPrefix
) with our build id and the provided pathYou can see an example of this function below for reference
Use the
getCdnUrl
function in my app where I have paths topublic
filesFor example,
/favicon.ico
becomesgetCdnUrl('/favicon.ico')
Include a rewrite rule in my
web.config
to rewrite incoming requests to remove the build id from any matching pathsI have included this rule below for reference
Include a client cache rule in my
web.config
to add aCache-Control
header to responses to requests for files in thepublic
folderI have included this rule below for reference
Include the build id as an application setting in my deployment task
Azure app services exposes application settings as environment variables
The getCdnUrl
looks like this:
const getCdnUrl = (path) => {
if (!path || !baseCdnUrl || !nextBuildId) {
return path
}
// joinUrlSegments has not been included for brevity, but can be seen in the sample app repo
return joinUrlSegments([baseCdnUrl, nextBuildId, path])
}
The rewrite rule is:
<!-- The build id pattern is looking for something resembling a Git commit ID (SHA-1 hash) -->
<rule name="RemoveBuildId">
<match url="([0-9a-f]{40})\/(.+)$" />
<action type="Rewrite" url="{R:2}" />
</rule>
And the cache rule is:
<location path="public">
<system.webServer>
<staticContent>
<!-- Public assets should be served via the CDN with cache busting so a long cache expiration is safe -->
<clientCache cacheControlCustom="public,immutable" cacheControlMode="UseMaxAge" cacheControlMaxAge="365.00:00:00" />
</staticContent>
</system.webServer>
</location>
With this approach in place, requests to public
files are made via the CDN with a far-future max-age
and the cache is busted by a new build because the new build id results in a different request path being used.
This all works well as long as I remember to use the getCdnUrl
function anyway!
Supporting Application Insights
If you don't have a different preferred tool for application and performance monitoring then I would recommend adding Application Insights (app insights) to your app as, quite frankly, it's better than nothing.
Adding app insights also required making modifications to my application. I had personally not hooked up app insights to a Node + React app before (I had only ever used it with ASP.NET apps) so I concentrated on just getting the basics in place. I'm sure there is room for improvement (for example, I'm pretty sure there is a lot of "dead weight" in the dependencies I have added so would love to streamline that), but it does the job I need it to do, which is to allow me to monitor my app on both the client-side and server-side.
Client-side monitoring
On the client-side, Microsoft provide a React plugin that enables tracking of route changes (yes, want) and React components usage statistics (no, don't want).
Even though I'm interested in only 50% of these features the implementation looked fine for my purposes and it seemed a better choice to use this than to start from scratch so I added the required dependencies, and started to implement the plugin using the documentation provided.
A Next app is of course a React app, but the sample code in the docs is written for a "general" React app and doesn't account for specific implementations or frameworks such as Next (not that I would expect it to). The sample that seemed the most easily transferrable was the sample for using React context so I used that as my starting point to create my own AppInsightsContextProvider
component that I could use in a Custom App by wrapping it around the main page Component
:
// The rest of the Custom App component has been omitted for brevity
function MyApp({ Component, pageProps }) {
return (
<AppInsightsContextProvider>
<Component {...pageProps} />
</AppInsightsContextProvider>
)
}
The AppInsightsContextProvider
does the following:
Configures and initalises the app insights javascript SDK for use in the app
Calls
trackPageView
on initialisation to log the initial page viewSubscribes to the
routeChangeComplete
Next router event to calltrackPageView
on route changeAllows the use of the
useAppInsightsContext
hook that allows you to easily track custom metrics and events from inside your components
That's all I needed on the client side, but I needed to hook up server-side monitoring too.
Server-side monitoring
Just as on the client-side, Microsoft provide npm packages for monitoring Node apps. Also as on the client-side, the documentation and sample code Microsoft provides needed to be adapted to meet the specific needs of a Next app, or even more specifically, a Next app with a custom server.
I modified my server.js
to:
Configure the app insights SDK on server start
Including sending live metrics
Track requests to the server as they are handled
I didn't find a good explanation of the difference (if there is any), but I opted to use the
trackNodeHttpRequest
method (rather thantrackRequest
)
Just out of curiosity more than anything - it gives me an opportunity to try out custom metric tracking
There's a whole load of stuff that can be done with app insights, but I was happy that I had a solid foundation for now, and I could build on it as and when I needed to.
Azure... finally
After all that my application was finally in a good place, prepped and ready for hosting in Azure. I now needed to setup the required resources in Azure and then I could create a pipeline with which to build and deploy my app.
Setting up Azure resources
The full list of resources I setup for my sample application is:
Resource group
Application service plan
Application service
CDN profile
CDN endpoint
Application Insights
There are many ways in which these resources can be setup, and for any real-world application I would recommend looking into scripting the infrastructure via one of the many tools available, but for the purposes of the sample application I just set everything up via the Azure Portal UI.
I won't go into the details here as there is nothing particularly "special" about the setup, but there is more detail in the getting started guide in the sample app repo.
I do want to discuss one aspect of the setup though before we move on to setting up the pipeline, which is managing environment variables.
Environment variables
I will assume that you are familiar with environment variables and are probably using them in you Next applications to help manage configuration and settings between environments - it's a common approach and one that is well supported by Next.
Azure also has good suport for environment variables:
Variables in Azure Pipelines are exposed as environment variables
Application settings in Azure app services are exposed as environment variables
So far so good, but there is an additional concern that we need to think about, which is that if both the pipeline and the app service need to know about your environment variable(s) then you need to define them separately for both because they do not share configuration settings i.e. you must define them once as variables for use in the pipeline, and again separately as app settings for use in the app service.
Storing and managing the same settings in two different places is obviously not ideal, but there are (at least) a couple of ways we can mitigate this issue:
Store the settings that need to be used in both places in Azure key vault and link these to your pipeline via variable groups, and to your app service via app settings
Define the settings in the pipeline (in the script or via variable groups) and pass them to the app service via your deployment task's
appSettings
argument
With the first approach we still need to define the setting in both places, but at least the value is only managed in one place (Azure key vault); and with the second approach we manage the setting in Azure pipelines and it is effectively "pushed" to the app service on deployment.
There is no one size fits all solution to this and you may need to use more than one approach depending on your needs. You will also need to think about what environment variables are needed when and by what:
What is needed at build time or runtime (or both)?
What is needed by the client or the server (or both)?
Are the variable values going to be the same or different between environments?
It's possible you may need to go through a bit of trial and error to find the correct approach for your application.
Azure pipelines
The pipeline I created for the sample app does the following:
Executes
yarn install
Executes
yarn build
Copies all of the files to be deployed to a
dist
folderZips the contents of the
dist
folder and publishes it to the build artifacts locationUses the zip from the build artifacts location to zip deploy to the target app service
Overall the setup of the pipeline is fairly straight-forward so I won't go into the details here, but I have documented the setup in more detail in the getting started section of the sample app repo.
Running the pipeline
The pipeline for the sample application runs on each push to my main
branch. It builds the application and deploys it to an app service in a single target environment.
The application has its static assets served via the Azure CDN and I can monitor it (both client and server side) via app insights. All is well!
Conclusion
I do appreciate why Vercel and Netlify are popular choices for hosting (not just Next apps, I know you can do so much more too) as they make all this stuff just so easy and with no or low code.
Maybe there are easier or more efficient ways to achieve my requirements that I am yet to learn; I also certainly could have done less with the sample app, but I didn't want to sacrifice my requirements especially as the goal was eventually to have a production Next app running in Azure on my project and it would need to have things like a CDN and monitoring. At least now that I have been through this process it will be easier next time!
If I end up taking this approach more often then I will definitly revisit to see if I can make the various components of this solution easier to reuse and/or automate, but for now I'm happy with what I have learned on the way and I'm happy with the outcome!
If you've made it this far then thank you for reading, and please do check out the sample app if you're interested. Also please do ask questions or make any suggestions for improvement via GitHub issues.