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:
Contents
Objectives
By the end of this tutorial, you will be able to:
- Containerize Flask and Vue with a single Dockerfile using a multi-stage build
- Deploy an app to Heroku with Docker
- 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.9 -m venv env
$ source env/bin/activate
(env)$
(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:
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:15.7.0-alpine3.10 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?
- 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 theproduction
image and thebuild-vue
image is discarded. - The
production
image extends the nginx:stable-alpine image by installing Python, copying over the static files from thebuild-vue
image, copying over our Nginx config, installing the requirements, and running Gunicorn along with Nginx. - Take note of the
sed -i -e 's/$PORT/'"$PORT"'/g' /etc/nginx/conf.d/default.conf
command. Here, we're using sed to replace$PORT
in thedefault.conf
file with the environmental variablePORT
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 the 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, ⬢ lit-savannah-00898
https://lit-savannah-00898.herokuapp.com/ | https://git.heroku.com/lit-savannah-00898.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/lit-savannah-00898/web .
Push the image to the registry:
$ docker push registry.heroku.com/lit-savannah-00898/web
Release the image:
$ heroku container:release --app lit-savannah-00898 web
Make sure to replace
lit-savannah-00898
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.
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
- chmod +x ./release.sh
- ./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:
- Install cURL
- Build and tag the new image
- Log in to the Heroku Container Registry
- Push the image up to the registry
- 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
- chmod +x ./release.sh
- ./release.sh
Now, after installing cURL, we:
- Log in to the GitLab Container Registry
- Pull the previously pushed images (if they exist)
- Build and tag the new images (both
build-vue
andproduction
) - Push the images up to the GitLab Container Registry
- Log in to the Heroku Container Registry
- Push the
production
image up to the registry - 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!