Using Azure Pipelines to build and deploy a Next.js app to Azure app services

#Azure Pipelines #Next.js

tl;dr If you want to use Azure Pipelines to build and deploy a Next.js app with SSR and SSG (Automatic Static Optimisation and Incremental Static Regeneration) to Azure app services backed by a CDN and tracked by Application Insights then check out my next-azure sample app, which provides a working example and getting started guide that should be sufficient to get you up and running.

I should also point out that if you only need support for SSG in Azure then check out Azure Static Web Apps instead.

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 and publicassets) need to be cached by a CDN

  • Server 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 that

  • I need to make a small tweak to my package.json to use my custom server when running the start script so I will also need to deploy that also

"scripts": {
  "dev": "next dev",
  "build": "next build",
  "start": "node server.js"
}

The Next docs include an example server.js file, but that shows how to override Next's routing with a custom implementation, which I didn't want to do because I had no requirement to do so and because it disables some features such as Automatic Static Optimisation.

I ended up taking the source code for the Next server that ultimately runs when you run next start and then modified it to suit my needs. The result can be found in my sample app.

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 initially took the web.config file from Parveen's examples to use with my app, which was a great starting point, but I have modified it as I was developing my sample app to fulfill my own requirements.

Some of the modifications I made are discussed below, but the final result can also be found in my sample app.

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 not yarn

  • 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.

Despite not having been touched for 4 years (at time of writing) it worked a treat with a couple of small modifications. The first was necessary because it turned out that the global node_modules location is not on the app service's PATH when the deployment script is executed; and the second was that the yarn process would sometimes timeout and cause the deployment to fail.

  • The global node_modules location can be found by executing npm root -g via the Kudu console, so I updated deploy.cmd to use the full path to the yarn executable

  • yarn accepts an argument to increase the default timeout length so I set that as --network-timeout=100000

The updated deploy.cmd file can also be found in my sample app.

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 path

    • You can see an example of this function below for reference

  • Use the getCdnUrl function in my app where I have paths to public files

    • For example, /favicon.ico becomes getCdnUrl('/favicon.ico')

  • Include a rewrite rule in my web.config to rewrite incoming requests to remove the build id from any matching paths

    • I have included this rule below for reference

  • Include a client cache rule in my web.config to add a Cache-Control header to responses to requests for files in the public folder

    • I 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>

Next also allows you to configure rewrite rules in next.config.js, but if the request for the public file has reached Next then it's too late to rewrite as it will be treated as a route and not a static file path - this is why the rewrite is being handled by IIS.

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.

I don't love app insights, but it's fine and it's part of the Azure eco-system so is a sensible default in my opinion.

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 code for AppInsightsContextProvider component can be seen in the sample app.

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 view

  • Subscribes to the routeChangeComplete Next router event to call trackPageView on route change

  • Allows 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:

You can see all of the above by checking out the server.js file in the sample app.

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:

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.

This is different to hosting providers such as Vercel and Netlify where you only have to define your environment variables in one place.

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:

  1. 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

  2. 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 folder

  • Zips the contents of the dist folder and publishes it to the build artifacts location

  • Uses the zip from the build artifacts location to zip deploy to the target app service

This is a bit of a simplification, but it captures the key tasks.

The first time I ran the pipeline Next gave me a warning to say that next build performance was impacted because there was no Next.js cache detected. I added a Cache task to my pipeline to remedy this (I have contributed this to the Next no-cache docs to help anyone who might come across this in future).

I also added a Cache task to cache node_modules, which greatly cuts down the time needed for yarn install if no changes have been made to the yarn.lock file between builds.

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!

If you remember (I forgive you if you don't - this article has stretched on longer than I thought it would and I can hardly remember!) one of my requirements was that the pipeline target multiple environments, but this has not been implemented in the sample application. The underlying support for it (with environments and variable groups) is there as I did have it working in an earlier version, but I pulled it out as I thought it was overkill for the sample. I have implemented it in my project though.

I may return to it at some point though perhaps as a follow up article to this one.

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.