This plugin allows you to deploy any Nx application to Heroku. It is based on the Heroku CLI and should help you to achieve simple or complex deployments.
- It supports multi-procfile buildpacks so that each app in your Nx workspace can be deployed to a different Heroku app.
- It supports Heroku pipelines and multi-stage deployments (development, staging, production) for each app.
- It can be used in CI/CD pipelines (Github Actions, Gitlab CI, etc) or locally to deploy your apps to Heroku.
- It can create Heroku apps, addons, webhooks and drains automatically if they don't exist.
To deploy your application to Heroku, you need to have the Heroku CLI installed. In Github Actions, it comes already installed in the runners.
When running the executor, it will authenticate to Heroku with the credentials (email
, apiKey
) provided via the executors options.
To install the plugin, run the following command:
# with npm
npm install -D @getlarge/nx-heroku
# or with yarn
yarn add -D @getlarge/nx-heroku
To generate a target for your application, run the following command:
npx nx generate @getlarge/nx-heroku:deploy --projectName=my-app --org=your-heroku-team --appNamePrefix=your-app-prefix
# or to be prompted for the project name, omit specifying it
npx nx g @getlarge/nx-heroku:deploy
This will generate a deploy
target in your project.json
file.
To generate a target for your application, run the following command:
npx nx generate @getlarge/nx-heroku:promote --projectName=my-app --org=your-heroku-team --appNamePrefix=your-app-prefix
# or to be prompted for the project name, omit specifying it
npx nx g @getlarge/nx-heroku:promote
This will generate a promote
target in your project.json
file.
The nx-heroku:deploy
executor allows the deployment of an Nx application to a targeted Heroku app. The deployment will be done for each pipeline stage declared via the option config
(default: ['development'])
You can look at the executor schema to see all the options available.
When deploying an application, the following steps are executed:
- Set internal variables that are prefixed with HD to avoid conflicts with variables provided by the user (
variables
option) - Authentification to Heroku via .netrc file
- Set default options (branch to current branch, environment to development, watchDelay to 0)
- Set the Heroku app name. The Heroku app will be named after the pattern described in Conventions.
- Create project 'Procfile'
- Create static buildpack config (optional)
- Create Aptfile, to install extra Ubuntu dependencies before build (optional)
- Ensure the remote is added (and that the application is created).
- Merge
HD_
prefixed variables with the one provided in the options and set Heroku appconfig vars
. You can provide your variables that will be available at build time. They should be prefixed byHD_
, they will be added (without the prefix) to the Heroku app config automatically. The environment variablesHD_PROJECT_NAME
,HD_PROJECT_ENV
,HD_NODE_ENV
andHD_PROCFILE
will automatically be defined based on the project name and environment being deployed.PROCFILE
is required when using multi-procfile buildpack, it should be defined in each Heroku app to indicate the Procfile path for the given project. - Cleanup and register buildpacks.
Extra buildpacks can be provided by using
buildPacks
option, they will be installed in the order they are provided in the array. - Ensure the app is attached to a pipeline with a stage matching the environment provided in options If the Heroku app doesn't exist, it will be created and attach to an existing or new pipeline.
- Assign management member (optional)
- Register addons (optional)
- Register drain (optional)
- Register webhook (optional)
- Deploy (trigger build and release)
- Run health check (optional)
- Rollback if health check failed (optional)
sequenceDiagram
participant Nx as Nx Plugin
participant CLI as Heroku
participant Git as Git
participant App as Application
note over Nx,Nx: Set internal variables and default options
Nx->>Nx: Setup
Nx->>CLI: Heroku authentication with .netrc file
Nx->>Git: Add and commit Procfile
opt heroku-community/static is in buildPacks
Nx->>Git: Add and commit static.json config
end
opt heroku-community/apt is in buildPacks
Nx->>Git: Add and commit Aptfile buildpack config
end
Nx->>CLI: Create app remote branch
opt app does not exists
Nx->>CLI: Create app and bind remote branch
end
Nx->>CLI: Fetch app config vars
note over Nx,CLI: Merge HD_ prefixed variables, options variables<br/>and the config vars from the Heroku app
Nx->>CLI: Set app config vars
Nx->>CLI: Clear buildpacks
Nx->>CLI: Add buildpacks
Nx->>CLI: Check pipeline exists
opt pipeline does not exists
Nx->>CLI: Create pipeline
opt repositoryName is provided in options
Nx->>CLI: Connect the pipeline to the repository
end
end
Nx->>CLI: Attach the app to a pipeline
opt serviceUser is provided in options
Nx->>CLI: Add management member to the app
end
opt addons is provided in options
Nx->>CLI: Add addons to the app
end
opt drain is provided in options
Nx->>CLI: Add drain to the app
end
opt webhook is provided in options
Nx->>CLI: Add webhook to the app
end
opt resetRepo is set to true in options
Nx->>CLI: Reset the app repository
end
Nx->>Git: Build and release app
opt watchDelay is set to > 0
Nx->>Git: Wait for the app to be deployed until the timeout is reached
end
opt healthcheck (url) is provided in options
Nx->>App: Run healthcheck
opt healthcheck failed and rollbackOnHealthcheckFailed is set to true
Nx->>CLI: Rollback
CLI->>App: Restore app to previous state
end
end
For the given example project config:
{
"name": "frontend",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "apps/frontend/src",
"targets": {
...,
"deploy": {
"executor": "@getlarge/nx-heroku:deploy",
"options": {
"appNamePrefix": "aloes",
"procfile": "web: bin/start-nginx-solo",
"buildPacks": [
"heroku/nodejs",
"heroku-community/multi-procfile",
"heroku-community/nginx"
],
"variables": {
"NGINX_APP_ROOT": "dist/apps/frontend",
"YARN2_SKIP_PRUNING": "true"
},
"useForce": true,
"debug": true
}
},
}
}
You can run the deployment with :
export HEROKU_API_KEY=<your_heroku_api_key>
export HEROKU_EMAIL=<your_heroku_account_email>
# this will build and release the applications `my-app-frontend-development` and `my-app-frontend-staging` to Heroku
npx nx run frontend:deploy --config 'development,staging' --appPrefixName my-app --apiKey $HEROKU_API_KEY --email $HEROKU_EMAIL
The nx-heroku:promote
executor allows to promote an existing Heroku app from a pipeline. The promotion will be done for each pipeline stage declared via the option config
(default: 'staging')
The promotion can be done :
- from development to staging by setting the config to 'staging',
- from staging to production by setting the config to 'production'
When promoting an application, the following steps are executed:
- Check that pipeline exists
- Check that the app to promote is attached to the pipeline, if not create it and attach it.
- Merge config vars from the promoted app with the
variables
option. - Promote the app to the next stage
- Assign management member (optional)
You can run the promotion with :
export HEROKU_API_KEY=<your_heroku_api_key>
export HEROKU_EMAIL=<your_heroku_account_email>
# this will promote the application `my-app-frontend-development` to `my-app-frontend-staging` to Heroku
npx nx run frontend:promote --config staging --appPrefixName my-app --apiKey $HEROKU_API_KEY --email $HEROKU_EMAIL
Beware with frontend applications, the app is not being rebuilt so variables set in the build from down stream stage are used.
The pipeline name deployed on Heroku is composed with the pattern ${appPrefixName}-${projectName}
, where :
appPrefixName
is the prefix name of the Heroku app, it can be customized via theappNamePrefix
option.projectName
is the name of the Nx project.
Examples:
aloes-my-service
aloes-frontend
The application names deployed on Heroku are composed with the pattern ${appPrefixName}-${projectName}-${environment}
, where :
appPrefixName
is the prefix name of the Heroku app, it can be customized via theappNamePrefix
option.projectName
is the name of the Nx project.environment
is the Heroku pipeline stage (development, staging or production), it can be customized via theconfig
option.
Due to some length limitations (32 characters), the environment name is shortened and the application name might be shortened as well.
Examples:
aloes-my-service-dev
aloes-frontend-staging
aloes-myapp-prod
This logic is applied in this Heroku helpers module
Heroku allows to run scripts called during the deployment process, for node projects we can make use of package.json scripts to run these hooks. See the Heroku documentation for more details.
For example, we can use the heroku-postbuild
script to provide our own application build process.
{
"scripts": {
"heroku-postbuild": "node tools/heroku/postbuild.js $PROJECT_NAME $PROJECT_ENV",
"heroku-cleanup": "node tools/heroku/cleanup.js $PROJECT_NAME $PROJECT_ENV"
}
}
I will provide some examples based on my experience with Nx apps deployment on Heroku.
The heroku-postbuild
script is used to build the application, it is executed after the npm install
command.
tools/heroku/postbuild.js
const { createPackageJson, createLockFile } = require('@nx/devkit');
const { execSync } = require('child_process');
const { writeFileSync } = require('fs');
async function refreshPackageJson(implicitDeps = [], skipDev = false) {
const projectGraph = await createProjectGraphAsync();
const { root: projectRoot } = data;
const options = {
projectRoot: data.root,
root: process.cwd(),
};
const packageJson = createPackageJson(projectName, projectGraph, options);
for (const dep of implicitDeps) {
packageJson.dependencies[dep] =
rootPackageJson.dependencies[dep] || rootPackageJson.devDependencies[dep];
}
if (skipDev) {
delete packageJson.devDependencies;
}
// we could sort dependencies here
// packageJson.dependencies = sortObjectByKeys(packageJson.dependencies);
// packageJson.devDependencies = sortObjectByKeys(packageJson.devDependencies);
const packageJsonPath = `apps/${projectName}/package.json`;
existsSync(packageJsonPath) && unlinkSync(packageJsonPath);
writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
// generate and store lock file
execSync(`npm i --prefix apps/${projectName} --package-lock-only`, {
stdio: 'inherit',
});
// or when using nx >= 15.x
const lockFile = createLockFile(packageJson);
const packageLockJsonPath = `apps/${projectName}/package-lock.json`;
writeFileSync(packageLockJsonPath, lockFile);
}
async function postbuild(argv) {
const projectName = argv[2] || process.env.PROJECT_NAME;
const projectEnv = argv[3] || process.env.PROJECT_ENV || 'production';
const implicitDeps = (argv[4] || process.env.IMPLICIT_DEPS || '').split(',');
console.log(`Heroku custom postbuild hook, ${projectName}:${projectEnv}`);
// refresh package-lock to be reused in cleanup phase
await refreshPackageJson(implicitDeps, true);
execSync(`npx nx build ${projectName} --c ${projectEnv} `, {
stdio: 'inherit',
});
}
postbuild(process.argv).catch((e) => {
console.error(e);
process.exit(1);
});
The heroku-clean
script is used to cleanup the application before the deployment, it is executed after the heroku-postbuild
script.
In this case we can remove the node_modules
folder and only install the given project dependencies to respect the slug size limitation.
tools/heroku/cleanup.js
/* eslint-disable @typescript-eslint/no-var-requires */
const { execSync } = require('child_process');
const { copyFileSync, existsSync, rmSync, writeFileSync } = require('fs');
const { resolve } = require('path');
function cleanup(argv) {
const projectName = argv[2] || process.env.PROJECT_NAME;
const projectEnv = argv[3] || process.env.PROJECT_ENV;
console.log(`Heroku custom cleanup hook, ${projectName}:${projectEnv}`);
// optionally you can authenticate on NPM here if you need to install private packages
const npmrc = resolve(process.cwd(), '.npmrc');
const registryUrl = `//registry.npmjs.org/`;
const authString =
registryUrl.replace(/(^\w+:|^)/, '') + ':_authToken=${NPM_TOKEN}';
const contents = `${authString}${os.EOL}`;
writeFileSync(npmrc, contents);
const packageJsonPath = 'package.json';
const packageLockJsonPath = 'package-lock.json';
// declare package and package-lock json file paths generated at postbuild phase
const appPackageJsonPath = `apps/${projectName}/${packageJsonPath}`;
const appPackageLockJsonPath = `apps/${projectName}/${packageLockJsonPath}`;
// remove all project dependencies and cache to respect slug size limitation
if (existsSync('node_modules')) {
rmSync('node_modules', { recursive: true, force: true });
}
if (existsSync('.yarn/cache')) {
rmSync('.yarn/cache', { recursive: true, force: true });
}
// only backend apps should have generated a custom package.json
if (existsSync(appPackageJsonPath)) {
console.log('Found generated package.json');
// reinstall production deps only
copyFileSync(appPackageJsonPath, packageJsonPath);
if (existsSync(appPackageLockJsonPath)) {
copyFileSync(appPackageLockJsonPath, packageLockJsonPath);
console.log(`Install dependencies with "npm ci"`);
execSync('npm ci --production --loglevel=error', { stdio: 'inherit' });
} else {
console.log(`Install dependencies with "npm install"`);
execSync('npm install --production --loglevel=error', {
stdio: 'inherit',
});
}
// try to remove unnecessary dependencies
console.log(
`Install and run node-prune | https://github.com/tj/node-prune`
);
execSync('curl -sf https://gobinaries.com/tj/node-prune | PREFIX=. sh');
execSync('./node-prune', { stdio: 'inherit' });
rmSync('node-prune');
}
// remove .npmrc file if exists
rmSync('.npmrc');
}
cleanup(process.argv);