Using modern Yarn for deployment with Node.js on Azure App Service
This blog post covers how to use Yarn versions 3.x for deployment on Azure App Services with Node.js.
Azure App Services currently uses Yarn ‘classic’ (1.x). This is still widely used in the ecosystem. With Yarn 2.x and greater (called Yarn ‘Berry’) is what is now billed as ‘modern’ Yarn, with version 3.x being the latest version.
One of the major differences between classic and modern is that classic, by default, still creates and uses node_modules
for package depedency management. While modern does not use or create node_modules
anymore - if using the default ‘Zero Installs’ approach, but rather just a .yarn/cache
directory with it’s Plug’n’Play functionality.
This can save time and size with the application, furthermore the .yarn/cache
folder can (and should) be commited to source, as opposed to not commiting node_modules
. More of this can be read here.
To find the source code for these files, visit this GitHub repo.
Getting started
Installation
Follow the steps on installing Yarn here.
Initialize a project
You can create a new project or use an existing one. If using an existing one, delete any node_modules
, package-lock.json
and yarn.lock
files before hand. You can also reference this project as an example.
Create a new directory for your application. Within this new directory, run the following commands:
- If on Node >= 16.20, run
corepack enable
. Else, runnpm i -g corepack
. - Next, run
yarn init -2
. You should now see 5 files and a directory get created:yarn.lock
.editorconfig
.yarnrc.yml
package.json
.pnp.cjs
.yarn/releases
.yarnrc.yml
points to the Yarn version it will be using under .yarn/releases
.
- Create a file named
server.js
in the directory you created for the project:
// IMPORTANT: This require() statement is needed!
require("./.pnp.cjs").setup();
const express = require("express");
const app = express();
const port = process.env.PORT || 3000;
app.get("/", (_req, res) => {
res.json({ msg: "azure-node-windows-yarn-berry" });
});
app.listen(port, () => console.log(`Application is listening on port: ${port}`));
IMPORTANT: Note the addition of the require
statement. This is needed for running with iisNode
and simplifies the approach of having to configure a startup command with yarn start
on Windows App Service. To configure a custom startup command would require HttpPlatformHandler
, this would lose benefits that iisNode
provides and is not recommended.
See this for more information.
- In your
package.json
, add the followingscripts
property:
{
...
"scripts": {
"start": "node server.js"
}
}
- Lastly, install
express
withyarn add express
. You should see something like the following:
$ yarn add express
➤ YN0000: ┌ Resolution step
➤ YN0000: └ Completed in 1s 124ms
➤ YN0000: ┌ Fetch step
A .yarn/cache
folder will now be generated. In this are .zip files related to the installed dependency.
Notice how no node_modules
where created. This is by design and a part of the default “Zero Installs” approach.
- Run the application with
yarn start
:
$ yarn start
Application is listening on port: 3000
The project structure at this time should look like the following:
Deployment - Windows
For Windows App Services, a web.config
is required. Create a file named web.config
in the root of your project with the following content:
<?xml version="1.0" encoding="utf-8"?>
<!--
This configuration file is required if iisnode is used to run node processes behind
IIS or IIS Express. For more information, visit:
https://github.com/tjanczuk/iisnode/blob/master/src/samples/configuration/web.config
-->
<configuration>
<system.webServer>
<!-- Visit http://blogs.msdn.com/b/windowsazure/archive/2013/11/14/introduction-to-websockets-on-windows-azure-web-sites.aspx for more information on WebSocket support -->
<webSocket enabled="false" />
<handlers>
<!-- Indicates that the server.js file is a node.js site to be handled by the iisnode module -->
<add name="iisnode" path="server.js" verb="*" modules="iisnode"/>
</handlers>
<rewrite>
<rules>
<!-- Do not interfere with requests for node-inspector debugging -->
<rule name="NodeInspector" patternSyntax="ECMAScript" stopProcessing="true">
<match url="^server.js\/debug[\/]?" />
</rule>
<!-- First we consider whether the incoming URL matches a physical file in the /public folder -->
<rule name="StaticContent">
<action type="Rewrite" url="public{PATH_INFO}"/>
</rule>
<!-- All other URLs are mapped to the node.js site entry point -->
<rule name="DynamicContent">
<conditions>
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="True"/>
</conditions>
<action type="Rewrite" url="server.js"/>
</rule>
</rules>
</rewrite>
<!-- 'bin' directory has no special meaning in node.js and apps can be placed in it -->
<security>
<requestFiltering>
<hiddenSegments>
<remove segment="bin"/>
</hiddenSegments>
</requestFiltering>
</security>
<!-- Make sure error responses are left untouched -->
<httpErrors existingResponse="PassThrough" />
<!--
You can control how Node is hosted within IIS using the following options:
* watchedFiles: semi-colon separated list of files that will be watched for changes to restart the server
* node_env: will be propagated to node as NODE_ENV environment variable
* debuggingEnabled - controls whether the built-in debugger is enabled
See https://github.com/tjanczuk/iisnode/blob/master/src/samples/configuration/web.config for a full list of options
-->
<!--<iisnode watchedFiles="web.config;*.js"/>-->
</system.webServer>
</configuration>
Local Git - Windows
The source code for this can be found here.
If wanting to deploy a project using Yarn 2.x or greater on Azure App Service Windows Node, there is two general approaches:
-
Install dependencies as normal on the local machine, and make sure that
.gitignore
does NOT have.yarn/
or.yarn/cache
in it. This will make sure this folder is commited, which is recommended. -
Or, do not commit
.yarn/cache
but instead build on the remote host (Kudu).
Option number 1 is preferred, as for as long as .yarn/cache
has the nessecary dependencies installed in it, there is no installation that needs to be done again on the remote host or during the build.
To use either approach, a custom deployment script is needed. Install the kuduScript package with npm
on your local machine.
Next, run the command kuduScript -y --node
. This will show the following:
Generating deployment script for node.js Web Site
Generated deployment script files
A .deployment
and deploy.cmd
file will be created.
Let’s now edit this file:
- On lines 55 and 98, remove both instances of
:SelectNodeVersion
andcall ::SelectNodeVersion
. -
Around lines 100, Replace the following of:
IF EXIST "%DEPLOYMENT_TARGET%\package.json" ( pushd "%DEPLOYMENT_TARGET%" call :ExecuteCmd !NPM_CMD! install --production IF !ERRORLEVEL! NEQ 0 goto error popd )
with this:
echo "Installing corepack.." call :ExecuteCmd npm i -g corepack echo "Setting yarn to path.." SET PATH=%PATH%;D:\local\AppData\npm IF EXIST "%DEPLOYMENT_TARGET%\package.json" ( IF EXIST "%DEPLOYMENT_TARGET%\.yarn\cache\" ( pushd "%DEPLOYMENT_TARGET%" echo ".yarn/cache checked in for 'Zero Installs', pushing cache and not running install.." IF !ERRORLEVEL! NEQ 0 goto error popd ) ) IF EXIST "%DEPLOYMENT_TARGET%\package.json" ( IF NOT EXIST "%DEPLOYMENT_TARGET%\.yarn\cache\" ( pushd "%DEPLOYMENT_TARGET%" echo ".yarn/cache not found, configuring yarn.." echo "Setting yarn version to stable.." call :ExecuteCmd yarn set version stable echo "Checking yarn version.." call :ExecuteCmd yarn -v echo "Running yarn install.." call :ExecuteCmd yarn install IF !ERRORLEVEL! NEQ 0 goto error popd ) )
The above does the following:
- Installs
corepack
- Updates
%PATH%
so we can call yarn from the deployment script location - Checks for
package.json
and.yarn\cache
. If the cache already exists, just push the content since we do not need to install anything now. - Or, if
.yarn\cache
does not exist, set yarn to the latest version (3.x) and install.
Both scenarios should produce no node_modules
. Deploy the application with Local Git. You should see a running application that only uses .yarn\cache
for its dependencies.
NOTE: The deployment script above and in other methods below is just an example of how to do this. This can be changed as desired.
During deployment, you should see output in your terminal like the following - this assuming .yarn/cache
was commited (recommended). Otherwise you would see packages being installed by yarn 3.x:
[master 516671d] initial commit
1 file changed, 3 insertions(+), 3 deletions(-)
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Delta compression using up to 8 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 326 bytes | 163.00 KiB/s, done.
Total 3 (delta 2), reused 0 (delta 0), pack-reused 0
remote: Updating branch 'master'.
remote: Updating submodules.
remote: Preparing deployment for commit id '516671d675'.
remote: Running custom deployment command...
remote: Running deployment command...
remote: Handling node.js deployment.
remote: Creating app_offline.htm
remote: KuduSync.NET from: 'C:\home\site\repository' to: 'C:\home\site\wwwroot'
remote: Copying file: 'web.config'
remote: Deleting app_offline.htm
remote: "Installing corepack.."
remote: ..
remote:
remote: changed 1 package, and audited 2 packages in 3s
remote:
remote: found 0 vulnerabilities
remote: "Setting yarn to path.."
remote: ".yarn/cache checked in for 'Zero Installs', pushing cache and not running install.."
remote: Finished successfully.
remote: Running post deployment command(s)...
remote: Triggering recycle (preview mode disabled).
remote: Deployment successful.
After a successfull deployment, you should see a directory structure like the below:
DevOps - Windows
The source code for this can be found here.
We can use Yarn 2.x or greater within a Azure DevOps pipeline for our node application.
In your build stage, add the NodeTool@0 task, followed by a script that we’ll execute to set the yarn version. Just as above, this script is written to push just the .yarn/cache
if it exists. This only installs if it finds no .yarn/cache
in the repo.
As mentioned above, this logic can be changed as desired:
- task: NodeTool@0
inputs:
versionSpec: '16.x'
displayName: 'Install Node.js'
# NOTE: Do not set environment variables like YARN_xxx or else you may encounter errors
# ex: Usage Error: Unrecognized or legacy configuration settings found: <someName> - run "yarn config -v" to see the list of settings supported in Yarn
- script: |
IF EXIST ".yarn/cache/" (
echo ".yarn/cache checked in for 'Zero Installs', pushing cache and not running install.."
) ELSE IF NOT EXIST ".yarn/cache/" (
echo ".yarn/cache doesn't exist, running yarn set version and install.."
echo "Setting yarn version to latest (3.x).."
yarn set version stable
echo "Checking yarn version.."
yarn -v
echo "Running yarn install.."
yarn install
)
displayName: 'Check for .yarn cache'
GitHub Actions - Windows
The source code for this can be found here.
Using the same project we created above, we can use this same approach in GitHub Actions. Create an actions .yaml
or edit an existing one. Add the following task of actions/setup-node@v1:
- name: Set up Node.js version
uses: actions/setup-node@v1
with:
node-version: '16.x'
- name: Check for .yarn cache
run: |
if (Test-Path -Path '.yarn\cache\') {
echo '.yarn/cache checked in for "Zero Installs", pushing cache and not running install..'
} elseif (-not (Test-Path -Path '.yarn\cache\')) {
echo '.yarn/cache doesnt exist, running yarn set version and install..'
echo 'Setting yarn version to latest (3.x)..'
yarn set version stable
echo 'Checking yarn version..'
yarn -v
echo 'Running yarn install..'
yarn install
}
Deployment - Linux
One big difference between running this on Windows and Linux is that you can specify a startup command easily and set this to yarn start
via the Azure Portal under Configuration -> General Settings. Therefor side-stepping the need for the require("./.pnp.cjs").setup();
code within your .js
entrypoint.
If you are wanting Oryx to run the application automatically without the need for a explicit startup command - then leave this blank - as Oryx should run this via yarn start
.
However, it may be advised to keep the require() function with your code, in case you want to avoid using a package manager for starting the application and use node
(ex. node server.js
)
We’ll reuse the same project as created above initially. web.config can be deleted when deploying to Linux App Services since this is ignored as there is no IIS being used.
Linux - Local Git
The source code for this can be found here.
Use the same project as above - and run the following in the root of your directory:
kuduScript --node -y -t bash
This will generate a deploy.sh
and .deployment
file.
NOTE: The
deploy.cmd
file can be deleted as it will be ignored for Linux App Services
Delete the selectNodeVersion
function on line 70 and the call on line 110. This function is not needed.
Lastly, change this:
# 3. Install npm packages
if [ -e "$DEPLOYMENT_TARGET/package.json" ]; then
cd "$DEPLOYMENT_TARGET"
echo "Running $NPM_CMD install --production"
eval $NPM_CMD install --production
exitWithMessageOnError "npm failed"
cd - > /dev/null
fi
To this:
if [ -e "$DEPLOYMENT_TARGET/package.json" ]; then
if [ -d "$DEPLOYMENT_TARGET/.yarn/cache/" ]; then
cd "$DEPLOYMENT_TARGET"
echo ".yarn/cache checked in for 'Zero Installs', pushing cache and not running install.."
elif [ ! -d "$DEPLOYMENT_TARGET/.yarn/cache/" ]; then
cd "$DEPLOYMENT_TARGET"
echo ".yarn/cache not found, configuring yarn.."
echo "Setting yarn version to stable.."
yarn set version stable
echo "Checking yarn version.."
yarn -v
echo "Running yarn install.."
yarn install
fi
fi
This contains the same logic we’ve discussed in the Windows section. This will check if .yarn/cache
exists or not. If it does, it only pushes the cache - and does no installs. If it doesn’t, it installs on the remote host.
As mentioned, it is recommended to push this cache. After a successful deployment, and using the set up we did in the Getting Started section, should show that no node_modules
are being used here, and just the .yarn/cache
.
Linux - DevOps
The source code for this can be found here.
Just as on Windows, add a NodeTool@0
task with the below logic:
- task: NodeTool@0
inputs:
versionSpec: '16.x'
displayName: 'Install Node.js'
- script: |
if [ -d ".yarn/cache/" ]; then
echo ".yarn/cache checked in for 'Zero Installs', pushing cache and not running install.."
elif [ ! -d ".yarn/cache/" ]; then
echo ".yarn/cache not found, configuring yarn.."
echo "Setting yarn version to stable.."
yarn set version stable
echo "Checking yarn version.."
yarn -v
echo "Running yarn install.."
yarn install
fi
displayName: 'Check for .yarn cache or install packages'
Linux - GitHub Actions
The source code for this can be found here.
Also like in the Windows section above, add a actions/setup-node@v1
task in your Build portion of the GitHub Actions .yaml
file with the following:
- name: Set up Node.js version
uses: actions/setup-node@v1
with:
node-version: '16.x'
- name: Check for .yarn cache
run: |
if [ -d ".yarn/cache/" ]; then
echo ".yarn/cache checked in for 'Zero Installs', pushing cache and not running install.."
elif [ ! -d ".yarn/cache/" ]; then
echo ".yarn/cache not found, configuring yarn.."
echo "Setting yarn version to stable.."
yarn set version stable
echo "Checking yarn version.."
yarn -v
echo "Running yarn install.."
yarn install
fi
As mentioned earlier, the logic for this can be changed as needed. The main goal is to set the yarn version to stable (currently 3.x) if needing to run any installs.
Troubleshooting
node_modules are being generated
If you see node_modules
being created, check the following:
- Ensure that yarn is not using 1.x
- in
.yarnrc.yml
, ensure thatnodeLinker
is not set tonode_modules
- Or, a different package manager is being used (eg.,
npm
)
HTTP 500s are shown (Windows)
If HTTP 500.1xxx status codes are thrown - which are iisnode
specific, review the following:
- A valid
web.config
is pushed and pointing to your entrypoint.js
file, which is also using theiisnode
handler. require("./.pnp.cjs").setup();
is the first line in your entrypoint.js
file.- Check if
iisnode.yml
is being used specifically and usingnodeProcessCommandLine
. If a require() statement is not being used as described above, setting custom startup commands here may otherwise fail.
Ensure that App Service Logs are turned on as well.
Container is crashing (Linux)
As above, ensure that App Service Logs are turned on.
- Check if
require("./.pnp.cjs").setup();
is the first line in your entrypoint.js
file. - If it not, ensure that
yarn
is being used in the Startup command (eg.,yarn start
) or else packages may not resolve correctly.