Terraforming GitLab & Heroku to deploy Dockerised apps

Philip Jones
NOVA Wealth
Published in
3 min readMay 21, 2019

This is a short article that explains how to deploy a dockerised app to Heroku using GitLab CI and Terraform. This creates a fully automated and repeatable build system.

Terraform is used to setup CI/CD on Gitlab to Heroku.

Terraform

Rather than use the Heroku CLI tooling, we use Terraform to create and specify anything on Heroku. This ensures that we can define our infrastructure as code, rather than relying on manual actions. In addition we store the Terraform code in git, allowing a fully auditable history of changes.

To start with Terraform we need to configure providers for Heroku and GitLab,

provider "gitlab" {
token = "example" # Access token
}
provider "heroku" {
email = "${var.heroku_username}"
api_key = "${var.heroku_api_key}"
}

running terraform init should confirm these have been setup. Next we need to create a Heroku app using the container stack,

resource "heroku_app" "default" {
name = "${var.name}"
region = "eu" # Or "us"
stack = "container"
}

running terraform apply should then create this application. Finally we need to create the Gitlab repository with the relevant protected environment variables to allow the CI scripts to push to Heroku,

resource "gitlab_project" "default" {                                                                                                                                                                       
name = "${var.name}"
visibility_level = "private"
default_branch = "master"
merge_method = "ff"
only_allow_merge_if_pipeline_succeeds = true
}
resource "gitlab_project_variable" "heroku_app" {
key = "HEROKU_APP"
value = "${heroku_app.default.name}"
project = "${gitlab_project.default.id}"
protected = true
}
resource "gitlab_project_variable" "heroku_token" {
key = "HEROKU_API_KEY"
value = "${var.heroku_api_key}"
project = "${gitlab_project.default.id}"
protected = true
}
resource "gitlab_project_variable" "heroku_username" {
key = "HEROKU_USERNAME"
value = "${var.heroku_username}"
project = "${gitlab_project.default.id}"
protected = true
}

again running terraform apply should show all of these settings having been applied.

GitLab CI & CD

The GitLab CI system is used to lint and test the code before pushing the docker image to Heroku and setting the image live. It is setup via a .gitlab-ci.yml configuration file in the repository root. The lint-test (CI) stage, which lints then tests the frontend code and dockerfile works as follows,

stages:
- lint-test
- push

frontend-ci:
stage: lint-test
image: node:10-alpine

before_script:
- apk --update add yarn
- yarn install

script:
- yarn run lint
- yarn run test

docker-ci:
stage: lint-test
image: hadolint/hadolint:latest-debian

script:
- hadolint Dockerfile

note our systems have package.json scripts to lint and test the code and we use yarn rather than npm.

If the lint-test stage passes GitLab will move onto the push (CD) stage, which we have setup to build a docker image, push it to Heroku and then set that image live as a release,

heroku-cd:
stage: push
image: docker:latest

services:
- docker:dind

before_script:
- apk add --update curl

script:
- >
docker login registry.heroku.com
--username $HEROKU_USERNAME
--password $HEROKU_API_KEY
- >
docker build --build-arg CI_COMMIT_SHA=$CI_COMMIT_SHA
-t registry.heroku.com/$HEROKU_APP/web .
- docker push registry.heroku.com/$HEROKU_APP/web
- >
curl --fail
-X PATCH "https://api.heroku.com/apps/$HEROKU_APP/formation"
-H 'Content-Type:application/json'
-H 'Accept:application/vnd.heroku+json; version=3.docker-releases'
-H "Authorization:Bearer $HEROKU_API_KEY"
-d '{"updates":[{"type":"web","docker_image":"'$(docker inspect registry.heroku.com/$HEROKU_APP/web --format={{.Id}})'"}]}'
only:
- master

note that spaces after : should be avoided, as the YAML parser will confuse them for YAML key value pairs. Also note that > indicates a multiline command with the newlines being replaced with spaces.

Dockerised app

Finally we need to Dockerise the application, to do this we have a Dockerfile that builds the code and bundles it with the correct command to run it.

FROM node:10-alpine

# hadolint ignore=DL3018
RUN apk --no-cache add yarn dumb-init

ENTRYPOINT ["dumb-init"]
CMD ["yarn", "run", "serve"]

COPY . /app/
WORKDIR /app/
RUN yarn install && yarn run build

the package.json script yarn run serve then serves the app. With Heroku it is key that the application is served over the port defined in the environment variable $PORT, via a server snippet like,

const express = require('express');
const port = process.env.PORT || 8080; // Default to 8080 in dev
const app = express();
...
app.listen(port);

Advancements

This serves as a great starter setup, it doesn’t however allow for rollbacks, canary deploys or any other useful CD tooling. All of these can be added over time as the application and deployment grow. It does however fully automate the CI and CD tool chain, requiring no manual steps to setup and start using.

--

--

Philip Jones
NOVA Wealth

Maintainer of Quart, Hypercorn and various other Python HTTP projects.