python and vue

Deploying a Flask and Vue App to Heroku with Docker and Gitlab CI

Deploying a Flask and Vue App to Heroku with Docker and Gitlab CI




This post looks at how to containerize a full-stack web app powered by Flask and Vue and deploy it to Heroku using Gitlab CI.

This is an intermediate-level tutorial. It assumes that you a have basic working knowledge of Vue, Flask, and Docker. Review the following resources for more info:

  1. Introduction to Vue
  2. Flaskr: Intro to Flask, Test-Driven Development (TDD), and JavaScript
  3. Developing a Single Page App with Flask and Vue.js
  4. Get started with Docker and Docker Compose

Contents

Objectives

By the end of this tutorial, you will be able to:

  1. Containerize Flask and Vue with a single Dockerfile using a multi-stage build
  2. Deploy an app to Heroku with Docker
  3. Configure GitLab CI to deploy Docker images to Heroku

Project Setup

If you'd like to follow along, clone down the flask-vue-crud repo from GitHub, create and activate a virtual environment, and then spin up the Flask app:

$ git clone https://github.com/testdrivenio/flask-vue-crud
$ cd flask-vue-crud
$ cd server
$ python3.7 -m venv env
$ source env/bin/activate
(env)$ pip install -r requirements.txt
(env)$ python app.py

The above commands, for creating and activating a virtual environment, may differ depending on your environment and operating system.

Point your browser of choice at http://localhost:5000/ping. You should see:

"pong!"

Then, install the dependencies and run the Vue app in a different terminal tab:

$ cd client
$ npm install
$ npm run serve

Navigate to http://localhost:8080. Make sure the basic CRUD functionality works as expected, and then kill both apps:

v1 app

Want to learn how to build this project? Check out the Developing a Single Page App with Flask and Vue.js blog post.

Docker

Let's start with Docker. Add the following Dockerfile to the project root.

# build
FROM node:11.12.0-alpine as build-vue
WORKDIR /app
ENV PATH /app/node_modules/.bin:$PATH
COPY ./client/package*.json ./
RUN npm install
COPY ./client .
RUN npm run build

# production
FROM nginx:stable-alpine as production
WORKDIR /app
RUN apk update && apk add --no-cache python3 && \
    python3 -m ensurepip && \
    rm -r /usr/lib/python*/ensurepip && \
    pip3 install --upgrade pip setuptools && \
    if [ ! -e /usr/bin/pip ]; then ln -s pip3 /usr/bin/pip ; fi && \
    if [[ ! -e /usr/bin/python ]]; then ln -sf /usr/bin/python3 /usr/bin/python; fi && \
    rm -r /root/.cache
RUN apk update && apk add postgresql-dev gcc python3-dev musl-dev
COPY --from=build-vue /app/dist /usr/share/nginx/html
COPY ./nginx/default.conf /etc/nginx/conf.d/default.conf
COPY ./server/requirements.txt ./
RUN pip install -r requirements.txt
RUN pip install gunicorn
COPY ./server .
CMD gunicorn -b 0.0.0.0:5000 app:app --daemon && \
      sed -i -e 's/$PORT/'"$PORT"'/g' /etc/nginx/conf.d/default.conf && \
      nginx -g 'daemon off;'

What's happening here?

  1. We used a multi-stage build to reduce the final image size. Essentially, build-vue is a temporary image that's used to generate a production build of the Vue app. The production static files are then copied over to the production image and the build-vue image is discarded.
  2. The production image extends the nginx:stable-alpine image by installing Python, copying over the static files from the build-vue image, copying over our Nginx config, installing the requirements, and running Gunicorn along with Nginx.
  3. Take note of the sed -i -e 's/$PORT/'"$PORT"'/g' /etc/nginx/conf.d/default.conf command. Here, we are using sed to replace $PORT in the default.conf file with the environmental variable PORT supplied by Heroku.

Next, add a new folder to the project root called "nginx", and then add a new config file to that folder called default.conf:

server {
  listen $PORT;

  root /usr/share/nginx/html;
  index index.html index.html;

  location / {
    try_files $uri /index.html =404;
  }

  location /ping {
    proxy_pass          http://127.0.0.1:5000;
    proxy_http_version  1.1;
    proxy_redirect      default;
    proxy_set_header    Upgrade $http_upgrade;
    proxy_set_header    Connection "upgrade";
    proxy_set_header    Host $host;
    proxy_set_header    X-Real-IP $remote_addr;
    proxy_set_header    X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header    X-Forwarded-Host $server_name;
  }

  location /books {
    proxy_pass          http://127.0.0.1:5000;
    proxy_http_version  1.1;
    proxy_redirect      default;
    proxy_set_header    Upgrade $http_upgrade;
    proxy_set_header    Connection "upgrade";
    proxy_set_header    Host $host;
    proxy_set_header    X-Real-IP $remote_addr;
    proxy_set_header    X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header    X-Forwarded-Host $server_name;
  }
}

To test locally, first remove all instances of http://localhost:5000 in client/src/components/Books.vue and client/src/components/Ping.vue. For example, the getBooks method in the Books component should now look like:

getBooks() {
  const path = '/books';
  axios.get(path)
    .then((res) => {
      this.books = res.data.books;
    })
    .catch((error) => {
      // eslint-disable-next-line
      console.error(error);
    });
},

Next, build the image and run the container in detached mode:

$ docker build -t web:latest .
$ docker run -d --name flask-vue -e "PORT=8765" -p 8007:8765 web:latest

Notice how we passed in an environment variable called PORT. If all went well, then we should see this variable in the default.conf file within the running container:

$ docker exec flask-vue cat ../etc/nginx/conf.d/default.conf

Ensure Nginx is listening on port 8765: listen 8765;. Also, ensure then app is running at http://localhost:8007/ in your browser. Stop then remove the running container once done:

$ docker stop flask-vue
$ docker rm flask-vue

Heroku

Sign up for a Heroku account (if you don’t already have one), and then install the Heroku CLI (if you haven't already done so).

Create a new app:

$ heroku create
Creating app... done, ⬢ safe-forest-46536
https://safe-forest-46536.herokuapp.com/ | https://git.heroku.com/safe-forest-46536.git

Log in to the Heroku Container Registry:

$ heroku container:login

Re-build the image and tag it with the following format:

registry.heroku.com/<app>/<process-type>

Make sure to replace <app> with the name of the Heroku app that you just created and <process-type> with web since this will be a web dyno.

For example:

$ docker build -t registry.heroku.com/safe-forest-46536/web .

Push the image to the registry:

$ docker push registry.heroku.com/safe-forest-46536/web

Release the image:

$ heroku container:release --app safe-forest-46536 web

Make sure to replace safe-forest-46536 in each of the above commands with the name of your app.

This will run the container. You should be able to view the app at https://APP_NAME.herokuapp.com.

GitLab CI

Sign up for a GitLab account (if necessary), and then create a new project (again, if necessary).

Retrieve your Heroku auth token:

$ heroku auth:token

Then, save the token as a new variable called HEROKU_AUTH_TOKEN within your project's CI/CD settings: Settings > CI / CD > Variables.

gitlab config

Next, add a GitLab CI/CD config file called .gitlab-ci.yml to the project root:

image: docker:stable
services:
  - docker:dind

variables:
  DOCKER_DRIVER: overlay
  HEROKU_APP_NAME: <APP_NAME>
  HEROKU_REGISTRY_IMAGE: registry.heroku.com/${HEROKU_APP_NAME}/web

stages:
  - build

docker-build:
  stage: build
  script:
    - apk add --no-cache curl
    - docker build
        --tag $HEROKU_REGISTRY_IMAGE
        --file ./Dockerfile
        "."
    - docker login -u _ -p $HEROKU_AUTH_TOKEN registry.heroku.com
    - docker push $HEROKU_REGISTRY_IMAGE
    - ./release.sh

release.sh:

#!/bin/sh


IMAGE_ID=$(docker inspect ${HEROKU_REGISTRY_IMAGE} --format={{.Id}})
PAYLOAD='{"updates": [{"type": "web", "docker_image": "'"$IMAGE_ID"'"}]}'

curl -n -X PATCH https://api.heroku.com/apps/$HEROKU_APP_NAME/formation \
  -d "${PAYLOAD}" \
  -H "Content-Type: application/json" \
  -H "Accept: application/vnd.heroku+json; version=3.docker-releases" \
  -H "Authorization: Bearer ${HEROKU_AUTH_TOKEN}"

Here, we defined a single build stage where we:

  1. Install cURL
  2. Build and tag the new image
  3. Log in to the Heroku Container Registry
  4. Push the image up to the registry
  5. Create a new release via the Heroku API using the image ID within the release.sh script

Make sure to replace <APP_NAME> with your Heroku app's name.

With that, commit and push your changes up to GitLab to trigger a new pipeline. This will run the build stage as a single job. Once complete, a new release should automatically be created on Heroku.

Finally, update the config script to take advantage of Docker layer caching:

image: docker:stable
services:
  - docker:dind

variables:
  DOCKER_DRIVER: overlay
  HEROKU_APP_NAME: <APP_NAME>
  CACHE_IMAGE: ${CI_REGISTRY}/${CI_PROJECT_NAMESPACE}/${CI_PROJECT_NAME}
  HEROKU_REGISTRY_IMAGE: registry.heroku.com/${HEROKU_APP_NAME}/web

stages:
  - build

docker-build:
  stage: build
  script:
    - apk add --no-cache curl
    - docker login -u $CI_REGISTRY_USER -p $CI_JOB_TOKEN $CI_REGISTRY
    - docker pull $CACHE_IMAGE:build-vue || true
    - docker pull $CACHE_IMAGE:production || true
    - docker build
        --target build-vue
        --cache-from $CACHE_IMAGE:build-vue
        --tag $CACHE_IMAGE:build-vue
        --file ./Dockerfile
        "."
    - docker build
        --cache-from $CACHE_IMAGE:production
        --tag $CACHE_IMAGE:production
        --tag $HEROKU_REGISTRY_IMAGE
        --file ./Dockerfile
        "."
    - docker push $CACHE_IMAGE:build-vue
    - docker push $CACHE_IMAGE:production
    - docker login -u _ -p $HEROKU_AUTH_TOKEN registry.heroku.com
    - docker push $HEROKU_REGISTRY_IMAGE
    - ./release.sh

Now, after installing cURL, we:

  1. Log in to the GitLab Container Registry
  2. Pull the previously pushed images (if they exist)
  3. Build and tag the new images (both build-vue and production)
  4. Push the images up to the GitLab Container Registry
  5. Log in to the Heroku Container Registry
  6. Push the production image up to the registry
  7. Create a new release via the Heroku API using the image ID within the release.sh script

For more on this caching pattern, review the "Multi-stage" section from the Faster CI Builds with Docker Cache post.

Make a quick change to one of the Vue components. Commit your code and again push it up to GitLab. Your app should be auto deployed to Heroku!





Join our mailing list to be notified about course updates and new tutorials.

 

Microservices with Docker, Flask, and React

Get the full course. Learn how to build, test, and deploy microservices to Amazon ECS powered by Docker, Flask, and React!

View the Course

Microservices with Docker, Flask, and React

Get the full course. Learn how to build, test, and deploy microservices to Amazon ECS powered by Docker, Flask, and React!


Table of Contents