Implement a CI/CD Pipeline with GitHub Actions and Vultr Kubernetes Engine

Updated on November 21, 2023
Implement a CI/CD Pipeline with GitHub Actions and Vultr Kubernetes Engine header image

Continuous integration and Continuous delivery (CI/CD) is now becoming the norm in developing software applications. This popularity of CI/CD is because adopting CI/CD pipeline allows software projects to release more often without compromising quality. In addition, with the rising popularity of Kubernetes in orchestrating containers in a microservice architecture, the CI/CD pipeline usually involves steps related to deploying the apps to the Kubernetes cluster. Implementing the CI/CD pipeline, which consists in communicating to the Kubernetes cluster, is not a trivial task since there are multiple components in the Kubernetes cluster that you need to configure to work well with the CI tool.

This article will teach you how to implement the CI/CD workflows using GitHub actions with Vultr Kubernetes Engine.

What is GitHub action

GitHub action is a CI/CD tool created by GitHub, similar to Jenkins or GitLab CI, allowing you to deliver your app to a specific environment automatically. GitHub has many actions created by communities or organizations, so you can easily connect your existing technology stack with GitHub without the need to build your integrators.

What is Vultr Kubernetes Engine

Vultr Kubernetes Engine (VKE) is a Vultr-managed Kubernetes cluster. Managing and maintaining system resources for deploying the Kubernetes cluster takes time due to the complexity of Kubernetes. Moreover, to scale the Kubernetes cluster so your application can handle more user workload, you need to add more Kubernetes nodes. Adding nodes to the Kubernetes cluster is a costly initial investment for the software team. A product like Vultr Kubernetes Engine is the solution to these problems.

With Vultr Kubernetes Engine, you can quickly scale up and down the Kubernetes cluster the way you want. You also use the pay-as-you-go pricing method, so you only need to pay for what you use, not investing a lot of money to create multi-node Kubernetes clusters to deploy your app.

The CI/CD workflow for the real-world app

To understand how CI/CD workflow works and how to build the workflow to deploy your app to the Kubernetes cluster, let's create a completed CI/CD workflow for a movie management app. The steps for implementing the CI/CD workflow would be like below:

  1. Create a Vultr MySQL database to store the movie management app data
  2. Create a Vultr Kubernetes engine so that you can deploy the app to it
  3. Implement the movie management app using FastAPI and Python
  4. Implement the unit tests for the app
  5. Implement the API test
  6. Create a cluster role in VKE
  7. Create a service account in VKE
  8. Create a secret for the service account to store the GitHub token
  9. Create an image pull secret for deployment
  10. Create a secret file for adding environment variables and credentials for the app
  11. Create the GitHub workflow definition file
  12. Create a deployment file
  13. Push code of project to GitHub repository
  14. Check the deployed app

The movie management app is an API service. It will allow users to sign up and sign in to the app. Then from the access token the users got after signing in, they can add, update, read, and delete their movies through the app.

Prerequisites

  1. A ready-to-use Ubuntu version 22.04
  2. Install the MySQL command line client tool on your machine
  3. Install Python version 3.9 on your machine
  4. Install the curl command line tool on your machine
  5. Create a GitHub account
  6. Create a Vultr account
  7. Install kubectl command line tool on your machine
  8. Install virtualenv tool on your machine

Create a Vultr MySQL database

The movie management app stores data using MySQL database. Self-managing a MySQL database is complicated, and it takes time to maintain it. Let's use a Vultr MySQL database, then.

  1. Create the Vultr MySQL database.

    From the Vultr products page, click the "+" button to choose the resources you want to create. Then select "Add managed databases" to add a database. Choose the "MySQL" option and the server configuration and region for the MySQL database. Below is the minimum MySQL server configuration option that would satisfy the article scenario.

    • Server Type: Cloud Compute
    • Plan:
      • CPUs: 1 vCPU
      • Storage: 55 GB
      • Memory: 2 GB DDR4
      • Number of Replica Nodes: 1
    • Server Location: Singapore
    • Label: movie-management-app

    Then click the "Deploy" button and wait a few minutes for the database to be created. Vultr automatically created the admin account and default database for you.

  2. Create a movie management database and new user in the Vultr MySQL database.

    From the "Users & Databases" tab on the Vultr MySQL page:

    • Create a new database named "movie_management"
    • Create a new user with any name. The username for the MySQL database for this article is "donald".
  3. Get the database connection info.

    From the overview page of the created Vultr MySQL database, you can see the connection details for your database. The example values for this article are as below:

    • username = donald
    • password = cuongledinh
    • host = vultr-prod-14c2c105-f528-4199-a00d-52676fa16673-vultr-prod-2d32.vultrdb.com
    • port = 16751
    • database = movie_management
  4. Create tables in the movie management database

    For the movie management app to work, you must create two tables: user_info and movie. Access the movie management database from your machine using the command line below:

     $ mysql --host="vultr-prod-14c2c105-f528-4199-a00d-52676fa16673-vultr-prod-2d32.vultrdb.com" --port=16751  --user="donald" --password="cuongledinh"

    Show the list of current databases by executing the below command:

     show databases;

    You should see the "movie_management" database in the result:

     +--------------------+
     | Database           |
     +--------------------+
     | defaultdb          |
     | information_schema |
     | movie_management   |
     | mysql              |
     | performance_schema |
     | sys                |
     +--------------------+
     6 rows in set (0,07 sec)

    Use the "movie_management" database by running the following code:

         use movie_management;

    Create a new user_info table by executing the following sql code:

     CREATE TABLE user_info(
     id INT(6) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
     username VARCHAR(50) NOT NULL,
     password VARCHAR(500) NOT NULL,
     fullname VARCHAR(50) NOT NULL
     );

    Create a new movie table with the following sql code:

     CREATE TABLE movie(
     id INT(6) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
     title VARCHAR(50) NOT NULL,
     description VARCHAR(500) NOT NULL
     );

    Now that you have finished creating the Vultr MySQL database for storing movie management app data. Let's move on to create a Vultr Kubernetes Engine.

Create a VKE cluster

  1. Add a new Vultr Kubernetes Engine cluster.

    From the Vultr Product page, click "+" button and choose "Add Kubernetes". The example values for the Vultr Kubernetes Engine in this article are:

    • Cluster name: movie-management
    • Kubernetes version: v1.25.6+2
    • Cluster location: Singapore
    • Number of nodes: 1
    • Label: movie-management
    • Node pool type: Regular Cloud Compute
    • Plan:
      • CPU: 1
      • Memory: 2048 MB
      • Storage: 55 GB
      • Bandwidth: 2000 GB

    Click "Deploy Now" and wait a few minutes for Vultr finishes creating a new Vultr Kubernetes Engine. Once the VKE is ready to use, click the "Download Configuration" button to download the Kubernetes configuration file. The example file for Kubernetes configuration is: "vke-d77dc163-b45d-436a-ab4b-e75b921581fa.yaml".

  2. Connect to Vultr Kubernetes Engine.

    Open your terminal in the VKE configuration file's directory, then execute the following command to create an environment variable for KUBECONFIG.

     $ export KUBECONFIG="vke-d77dc163-b45d-436a-ab4b-e75b921581fa.yaml"

    Using kubectl to get node information:

     $ kubectl get node

    You should be able to see similar output as below:

         NAME                            STATUS   ROLES    AGE   VERSION
     movie-management-7c617d5b34c0   Ready    <none>   6d    v1.25.6

    You have now finished adding a new Vultr Kubernetes Engine. Let's move on to implementing the movie management app.

Implement the movie management app

You will implement the movie management app using FastAPI and Python. Let's create a new directory to store the application code.

     $ mkdir ~/Projects/movie-management-app -p
     $ cd ~/Projects/movie-management-app
  1. Install the needed dependencies to implement the app.

    Create a requirements.txt file that stores all the dependencies you need to implement the app.

     $ nano requirements.txt

    Copy the following content into the requirements.txt file:

     anyio==3.6.2
     attrs==22.2.0
     bcrypt==4.0.1
     certifi==2022.12.7
     cffi==1.15.1
     click==8.1.3
     cryptography==39.0.0
     fastapi==0.89.1
     greenlet==2.0.1
     h11==0.14.0
     httpcore==0.16.3
     httpx==0.23.3
     idna==3.4
     iniconfig==2.0.0
     mysql-connector-python==8.0.32
     packaging==23.0
     pluggy==1.0.0
     protobuf==3.20.3
     pycparser==2.21
     pydantic==1.10.4
     PyJWT==2.6.0
     pytest==7.2.1
     rfc3986==1.5.0
     sniffio==1.3.0
     SQLAlchemy==2.0.0
     starlette==0.22.0
     typing_extensions==4.4.0
     uvicorn==0.20.0

    Create a new Python virtual environment to isolate your machine's movie management app dependencies with other Python projects.

     $ virtualenv venv

    Activate the virtual environment using:

      $ source venv/bin/activate

    Install all the required dependencies from requirements.txt file using the following:

      $ pip install -r requirements.txt
  2. Create app_utils.py file.

    The app_utils.py file is for implementing utility functions. When authorizing the user credential, you will define the functions for encoding and decoding access tokens.

    Create app_utils.py and open it using the following:

      $ nano app_utils.py

    Add the following content to it:

     from datetime import timedelta, datetime
     import jwt
     secret_key = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7"
     algorithm = "HS256"
    
    
     def create_access_token(*, data: dict, expires_delta: timedelta = None):
         to_encode = data.copy()
         if expires_delta:
             expire = datetime.utcnow() + expires_delta
         else:
             expire = datetime.utcnow() + timedelta(minutes=15)
         to_encode.update({"exp": expire})
         encoded_jwt = jwt.encode(to_encode, secret_key, algorithm=algorithm)
         return encoded_jwt
    
    
     def decode_access_token(*, data: str):
         to_decode = data
         return jwt.decode(to_decode, secret_key, algorithms=[algorithm])
  3. Create crud.py file.

    The crud.py file defines methods that allow the app to interact with the MySQL database to create, edit, retrieve, and delete data.

    Create and open crud.py by running:

     $ nano crud.py

    Copy the following content to crud.py file:

     import bcrypt
     from sqlalchemy.orm import Session
    
     import models
     import schemas
    
    
     def get_user_by_username(db: Session, username: str):
         return db.query(models.UserInfo).filter(models.UserInfo.username == username).first()
    
    
     def create_user(db: Session, user: schemas.UserCreate):
         hashed_password = bcrypt.hashpw(user.password.encode('utf-8'), bcrypt.gensalt())
         db_user = models.UserInfo(username=user.username, password=hashed_password, fullname=user.fullname)
         db.add(db_user)
         db.commit()
         db.refresh(db_user)
         return db_user
    
    
     def check_username_password(db: Session, user: schemas.UserAuthenticate):
         db_user_info: models.UserInfo = get_user_by_username(db, username=user.username)
         return bcrypt.checkpw(user.password.encode('utf-8'), db_user_info.password.encode('utf-8'))
    
    
     def create_new_movie(db: Session, movie: schemas.MovieBase):
         db_movie = models.Movie(title=movie.title, content=movie.content)
         db.add(db_movie)
         db.commit()
         db.refresh(db_movie)
         return db_movie
    
    
     def get_all_movies(db: Session):
         return db.query(models.Movie).all()
    
    
     def get_movie_by_id(db: Session, movie_id: int):
         return db.query(models.Movie).filter(models.Movie.id == movie_id).first()
    
    
     def delete_movie_by_id(db: Session, movie: schemas.Movie):
         db.delete(movie)
         db.commit()
  4. Create database.py file.

    The database.py file defines the configuration values to connect to the MySQL database. Create and open database.py file using the following:

     $ nano database.py

    Copy the following content to database.py file:

     from sqlalchemy import create_engine
     from sqlalchemy.ext.declarative import declarative_base
     from sqlalchemy.orm import sessionmaker
     import os
    
     DB_HOST = os.getenv('DB_HOST').strip()
     DB_USERNAME = os.getenv('DB_USERNAME').strip()
     DB_PASSWORD = os.environ.get('DB_PASSWORD').strip()
     DB_PORT = os.environ.get('DB_PORT').strip()
     DB_NAME = os.environ.get('DB_NAME').strip()
    
    
     SQLALCHEMY_DATABASE_URL = f"mysql+mysqlconnector://{DB_USERNAME}:{DB_PASSWORD}@{DB_HOST}: {DB_PORT}/{DB_NAME}"
    
     engine = create_engine(
         SQLALCHEMY_DATABASE_URL,
     )
     SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
     Base = declarative_base()
  5. Create models.py file.

    The models.py file is for creating classes that correspond to the MySQL database tables so that you can interact with the movie management database tables.

    Create and open models.py file:

     $ nano models.py

    Copy the following content to models.py file:

     from sqlalchemy import Column, Integer, String
     from database import Base
    
    
     class UserInfo(Base):
         __tablename__ = "user_info"
    
         id = Column(Integer, primary_key=True, index=True)
         username = Column(String, unique=True)
         password = Column(String)
         fullname = Column(String, unique=True)
    
    
     class Movie(Base):
         __tablename__ = "movie"
    
         id = Column(Integer, primary_key=True, index=True)
         title = Column(String)
         description = Column(String)
  6. Create schemas.py file.

    The schemas.py file defines Python classes so that you can conveniently interact with the request and response body of the APIs the app will create.

    Create and open schemas.py file using the following:

     $ nano schemas.py

    Copy the following content to schemas.py file:

     from pydantic import BaseModel
    
    
     class UserInfoBase(BaseModel):
         username: str
    
    
     class UserCreate(UserInfoBase):
         fullname: str
         password: str
    
    
     class UserAuthenticate(UserInfoBase):
         password: str
    
    
     class UserInfo(UserInfoBase):
         id: int
    
         class Config:
             orm_mode = True
    
    
     class Token(BaseModel):
         access_token: str
         token_type: str
    
    
     class TokenData(BaseModel):
         username: str = None
    
    
     class MovieBase(BaseModel):
         title: str
         content: str
    
    
     class Movie(MovieBase):
         id: int
    
         class Config:
             orm_mode = True
  7. Create main.py file.

    The main.py is the entry point of the application. You will create the application APIs inside this file.

    Create and open main.py file:

     $ nano main.py

    Copy the following content to it:

     import uvicorn
     from fastapi.security import OAuth2PasswordBearer
     from jwt import PyJWTError
     from sqlalchemy.orm import Session
     from fastapi import Depends, FastAPI, HTTPException
     from starlette import status
     import crud
     import models
     import schemas
     from app_utils import decode_access_token
     from crud import get_user_by_username
     from database import engine, SessionLocal
     from schemas import UserInfo, TokenData, UserCreate, Token
    
     models.Base.metadata.create_all(bind=engine)
    
     ACCESS_TOKEN_EXPIRE_MINUTES = 30
    
     app = FastAPI(debug=True)   
    
     def get_db():
         db = None
         try:
             db = SessionLocal()
             yield db
         finally:
             db.close()
    
    
     oauth2_scheme = OAuth2PasswordBearer(tokenUrl="authenticate")
    
    
     async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
         credentials_exception = HTTPException(
             status_code=status.HTTP_401_UNAUTHORIZED,
             detail="Could not validate credentials",
             headers={"WWW-Authenticate": "Bearer"},
         )
         try:
             payload = decode_access_token(data=token)
             username: str = payload.get("sub")
             if username is None:
                 raise credentials_exception
             token_data = TokenData(username=username)
         except PyJWTError:
             raise credentials_exception
         user = get_user_by_username(db, username=token_data.username)
         if user is None:
             raise credentials_exception
         return user
    
    
     @app.post("/user", response_model=UserInfo)
     def create_user(user: UserCreate, db: Session = Depends(get_db)):
         db_user = crud.get_user_by_username(db, username=user.username)
         if db_user:
             raise HTTPException(status_code=400, detail="Username already registered")
         return crud.create_user(db=db, user=user)
    
    
     @app.post("/authenticate", response_model=Token)
     def authenticate_user(user: schemas.UserAuthenticate, db: Session = Depends(get_db)):
         db_user = crud.get_user_by_username(db, username=user.username)
         if db_user is None:
             raise HTTPException(status_code=400, detail="Username not existed")
         else:
             is_password_correct = crud.check_username_password(db, user)
             if is_password_correct is False:
                 raise HTTPException(status_code=400, detail="Password is not correct")
             else:
                 from datetime import timedelta
                 access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
                 from app_utils import create_access_token
                 access_token = create_access_token(
                     data={"sub": user.username}, expires_delta=access_token_expires)
                 return {"access_token": access_token, "token_type": "Bearer"}
    
    
     @app.post("/movie", response_model=schemas.Movie)
     async def create_new_movie(movie: schemas.MovieBase, current_user: UserInfo = Depends(get_current_user)
                            , db: Session = Depends(get_db)):
         return crud.create_new_movie(db=db, movie=movie)
    
    
     @app.get("/movie")
     async def get_all_movies(current_user: UserInfo = Depends(get_current_user)
                          , db: Session = Depends(get_db)):
         return crud.get_all_movies(db=db)
    
    
     @app.get("/movie/{movie_id}")
     async def get_movie_by_id(movie_id, current_user: UserInfo = Depends(get_current_user)
                           , db: Session = Depends(get_db)):
         return crud.get_movie_by_id(db=db, movie_id=movie_id)
    
    
     @app.delete("/movie/{movie_id}", status_code=204)
     async def delete_movie_by_id(movie_id, current_user: UserInfo = Depends(get_current_user)
                              , db: Session = Depends(get_db)):
         movie_delete = crud.get_movie_by_id(db=db, movie_id=movie_id)
         if movie_delete:
             crud.delete_movie_by_id(db=db, movie=movie_delete)
    
    
     if __name__ == "__main__":
         log_config = uvicorn.config.LOGGING_CONFIG
         log_config["formatters"]["access"]["fmt"] = "%(asctime)s - %(levelname)s - %(message)s"
         log_config["formatters"]["default"]["fmt"] = "%(asctime)s - %(levelname)s - %(message)s"
         uvicorn.run(app, log_config=log_config)
  8. Create a Dockerfile file.

    A Dockerfile file defines steps for building a Docker image for the movie management app. You will work with the Docker image later when implementing the CI/CD workflow.

    Create and open Dockerfile by running:

     $ nano Dockerfile

    Copy the following content to Dockerfile:

     FROM python:3.9
     WORKDIR /app
     COPY requirements.txt /app/requirements.txt
     RUN pip install -r /app/requirements.txt
     COPY app_utils.py /app/app_utils.py
     COPY crud.py /app/crud.py
     COPY database.py /app/database.py
     COPY main.py /app/main.py
     COPY models.py /app/models.py
     COPY schemas.py /app/schemas.py
     COPY test_unit.py /app/test_unit.py
     CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8084"]

    Now that you finished implementing the movie management app. Let's move on to see how to run the app in the local environment and try to interact with the functionalities that the app provides.

Run the App

From the current terminal, run the following commands to define environment variables for the app to connect with the MySQL database:

$ export DB_HOST=vultr-prod-14c2c105-f528-4199-a00d-52676fa16673-vultr-prod-2d32.vultrdb.com
$ export DB_USERNAME=donald
$ export DB_PORT=16751
$ export DB_PASSWORD=thisisapassword
$ export DB_NAME=movie_management

Run the following command to bring up the app:

    $ uvicorn main:app --port 8084

You should see the app is up and running with the output below:

INFO:     Started server process [8485]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8084 (Press CTRL+C to quit)

Open a new terminal, then try to create a new user using curl:

$ curl --location --request POST 'http://localhost:8084/user' \
--header 'Content-Type: application/json' \
--data-raw '{
    "username": "new_user",
    "password": "12345",
    "fullname": "Just a new user"
}'

You should see the output showing a new user has been created.

 {"username":"new_user","id":1}

Authenticate the user to get the access token so that you can add a new movie later.

$ curl --location --request POST 'http://localhost:8084/authenticate' \
--header 'Content-Type: application/json' \
--data-raw '{
    "username": "new_user",
    "password":"12345"
}'

The output should look like as:

{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJuZXdfdXNlciIsImV4cCI6MTY3NTQ3OTg2MX0.8tDHI-NeIx4KdcnxrJzls3HyB2PfYHP9QKO4UFERNWo","token_type":"Bearer"}

Let's add a new movie using the access token above for authentication.

$ curl --location --request POST 'http://localhost:8084/movie' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJuZXdfdXNlciIsImV4cCI6MTY3NTQ3OTg2MX0.8tDHI-NeIx4KdcnxrJzls3HyB2PfYHP9QKO4UFERNWo' \
--data-raw '{
    "title": "It'\''s a beautiful life",
    "description":"An emotional classic movie about how life can beautiful even in storms."
}'

The output should look similar to the below:

{"title":"It's a beautiful life","description":"An emotional classic movie about how life can beautiful even in storms.","id":1}

You have finished implementing the movie management app in the local environment. Let's move on to how to write the application's unit test.

Writing unit tests

Create and open a new file named test_unit.py:

$ nano test_unit.py

Copy the following content to the test_unit.py file:

def test_encode_decode_access_token():
    from app_utils import create_access_token
    from datetime import timedelta
    input_data_create_access_token = {"sub": "cuongld"}
    access_token_expires = timedelta(minutes=10)
    access_token = create_access_token(data=input_data_create_access_token, expires_delta=access_token_expires)
    from app_utils import decode_access_token
    decoded_access_token = decode_access_token(data=access_token)
    assert decoded_access_token['sub'] == input_data_create_access_token['sub']

Here you have a test that checks whether the app can decode the encoded token correctly. To run the test from your terminal, execute the following command:

$ pytest test_unit.py

You should see the result as passed.

Writing an integration test

FastAPI provides easy support for implementing integration tests using the TestClient.

$ nano test_integration.py

Copy the following content to the file.

from fastapi.testclient import TestClient
from main import app

client = TestClient(app)


def test_read_main():
    response = client.post("/authenticate", json={"username": "new_user", "password": "12345"}, )
    assert response.status_code == 200

Here you check whether the response status code of the authentication API is 200 if you provide the correct user credentials.

Let's run the test using pytest. Note that the app needs to connect with the actual database since you run the integration test. You need to define the environment variables before running the test.

$ export DB_HOST=vultr-prod-14c2c105-f528-4199-a00d-52676fa16673-vultr-prod-2d32.vultrdb.com
$ export DB_USERNAME=donald
$ export DB_PORT=16751
$ export DB_PASSWORD=thisisapassword
$ export DB_NAME=movie_management
$ pytest test_integration.py

You should see the result as "pass". Now you successfully implemented the unit test and integration test for the app. Let's move on to create a cluster role in the Vultr Kubernetes Engine to set up the integration between GitHub actions and VKE.

Create a cluster role in VKE

From the folder where you have saved the VKE configuration file, create a file named clusterrole.yaml.

$ nano clusterrole.yaml

Please copy the following content to it.

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: continuous-deployment
rules:
  - apiGroups:
    - ''
    - apps
    - networking.k8s.io
  resources:
    - namespaces
    - deployments
    - replicasets
    - ingresses
    - services
    - secrets
  verbs:
    - create
    - delete
    - deletecollection
    - get
    - list
    - patch
    - update
    - watch

Run the following command to create a cluster role:

$ kubectl -f apply clusterrole.yaml

You should see a message that Kubernetes created a new cluster role.

Create a service account in VKE

From the terminal, run the following command to create a new service account named github-actions-kubernetes-vultr.

$ kubectl create serviceaccount github-actions-kubernetes-vultr

You should see that Kubernetes has created a new service account named github-actions-kubernetes-vultr. Then you create a ClusterRoleBinding to bind the continuous-deployment role to github-actions-kubernetes-vultr:

$ kubectl create clusterrolebinding continuous-deployment \
    --clusterrole=continuous-deployment
    --serviceaccount=default:github-actions-kubernetes-vultr

Run the following command to see details of the service account information:

$ kubectl get serviceaccounts github-actions-kubernetes-vultr -o yaml

You should see a similar output below:

apiVersion: v1
kind: ServiceAccount
metadata:
  creationTimestamp: "2023-01-30T10:06:29Z"
  name: github-actions-kubernetes-vultr
  namespace: default
  resourceVersion: "487023"
  uid: 43365457-4c6f-4b32-842f-fd379ab6512e

Create a secret for the service account to store the GitHub token

After having the service account with role biding for accessing the Kubernetes cluster, you need to create a secret for the service account. You will later use this secret in the GitHub workflow definition to allow GitHub action to set the Kubernetes context to deploy to Kubernetes. Create a new file named secret-service-account.yaml to store the definition for the secret.

$ nano secret-service-account.yaml

Copy the following content to the file:

apiVersion: v1
kind: Secret
metadata:
  name: secret-github-actions-kubernetes-vultr
  annotations:
    kubernetes.io/service-account.name: "github-actions-kubernetes-vultr"
type: kubernetes.io/service-account-token
data:
  extra: YmFyCg==

Run the following command to create a secret for the service-account:

$ kubectl apply -f secret-service-account.yaml

Get the yaml output of the secret you have just created above:

$ kubectl get secret secret-github-actions-kubernetes-vultr -o yaml

The output should look similar as below:

apiVersion: v1
data:
  ca.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURnVENDQW1tZ0F3SUJBZ0lJU3JJRzlyUHR3cjh3RFFZSktvWklodmNOQVFFTEJRQXdUekVMTUFrR0ExVUUKQmhNQ1ZWTXhGakFVQmdOVkJBY1REVk5oYmlCR2NtRnVZMmx6WTI4eEV6QVJCZ05WQkFvVENrdDFZbVZ5Ym1WMApaWE14RXpBUkJnTlZCQU1UQ2t0MVltVnlibVYwWlhNd0hoY05Nak13TVRJM01qTXhNVEF4V2hjTk1qZ3dNVEkzCk1qTXhNVEF4V2pCUE1Rc3dDUVlEVlFRR0V3SlZVekVXTUJRR0ExVUVCeE1OVTJGdUlFWnlZVzVqYVhOamJ6RVQKTUJFR0ExVUVDaE1LUzNWaVpYSnVaWFJsY3pFVE1CRUdBMVVFQXhNS1MzVmlaWEp1WlhSbGN6Q0NBU0l3RFFZSgpLb1pJaHZjTkFRRUJCUUFEZ2dFUEFEQ0NBUW9DZ2dFQkFOWFlyYnVhMFg1VlhoL1hwZDd2azVNeXFKL0liS3ozCm1nQ1N2VzlEK3ZBUTB1ZGEvWUJWUVBRSWcyaUZxTVpXeXlxS1BFRVEzblBvb09wYUVJVHNXN044aSt4VUp3dDMKc0U5cUd6UGhqYkVSdzc2c25CWlBENHFzUVpvTG4xM2tFbkZLL2thTEJSaDQ0bTA5dytzM3BBRm12elpRMmtnZwpZVzkzUHZuaWgyZVJISFJGNDJGR2xGVGo5N1hyRlBhVUpwOUNoRUZhUjJhaEF3RThXMEhQZmNoRzJNa0lYcTFsClZQbFM2UXVyR0xWbEs1Q1cxZUg0dGxxTTdwYWlBeWxJNVpnbWxqeUpGaDMvUE9WN1NmSlFZdzMyck41RjFVZnQKZVJrVUVWSDkvVjRGVjR6c1N6ZlUvWEVxWitKc21PNGQrOS90S09kMFZrMy81RTU4ek9yVkhmc0NBd0VBQWFOaApNRjh3RGdZRFZSMFBBUUgvQkFRREFnS0VNQjBHQTFVZEpRUVdNQlFHQ0NzR0FRVUZCd01DQmdnckJnRUZCUWNECkFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVFlFbC9JMW5xckFTYk9SK0FXTEN2dTAvZ0wKVURBTkJna3Foa2lHOXcwQkFRc0ZBQU9DQVFFQUtYSHFzMWRGbDNuTjEzTmlXa3pMT1ZIdnNCa2Q2aTZXN2pXYgowL01MRU9DR2pWRFFaMGVDazJhNDVEMGNlNmJHQTVvS2tCUTJHbEQ4bTJPMlNCTlVxV0JGeS8vYUtHenVKN0piCjcycVByN0RrYkxRemVFcnhtTWkwQWV5YmxQR2hkbkFWVy9SNTBWWHBWYmV5ZjhPT29CWHJIajRiUWNPRGV2NEYKZTJwaWh5bEEvK2dNSEZYMmZXcmdTSjZ6b0VmcHI4bHpDNW9OWW9iUWhyNXpjMVVSdWtwdXF4TG5vZ0JyOWVUZApnNjQ3UDlXV2pBYWpaaTFVZXRQYnlOZWVDNzNhdXViMEhsZnRNc3JKNW16TzFsVHRsVzcwWUZGUTBCUktUM3Y3Cmd6UWFCSUZNNTQ0SzQxcG5KWkhZOGxaNkFYTkg5MjBMRGhsZjR6RXNZaVFmYmFTN093PT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=
  extra: YmFyCg==
  namespace: ZGVmYXVsdA==
  token: ZXlKaGJHY2lPaUpTVXpJMU5pSXNJbXRwWkNJNklrYzFSVEJhWDFKcFpVbERiMnRLVm5SaVMydHFNMncyY1RjMWNUVklNakJPWDFoeGEwZFViVk53VmpnaWZRLmV5SnBjM01pT2lKcmRXSmxjbTVsZEdWekwzTmxjblpwWTJWaFkyTnZkVzUwSWl3aWEzVmlaWEp1WlhSbGN5NXBieTl6WlhKMmFXTmxZV05qYjNWdWRDOXVZVzFsYzNCaFkyVWlPaUprWldaaGRXeDBJaXdpYTNWaVpYSnVaWFJsY3k1cGJ5OXpaWEoyYVdObFlXTmpiM1Z1ZEM5elpXTnlaWFF1Ym1GdFpTSTZJbk5sWTNKbGRDMW5hWFJvZFdJdFlXTjBhVzl1Y3kxcmRXSmxjbTVsZEdWekxYWjFiSFJ5SWl3aWEzVmlaWEp1WlhSbGN5NXBieTl6WlhKMmFXTmxZV05qYjNWdWRDOXpaWEoyYVdObExXRmpZMjkxYm5RdWJtRnRaU0k2SW1kcGRHaDFZaTFoWTNScGIyNXpMV3QxWW1WeWJtVjBaWE10ZG5Wc2RISWlMQ0pyZFdKbGNtNWxkR1Z6TG1sdkwzTmxjblpwWTJWaFkyTnZkVzUwTDNObGNuWnBZMlV0WVdOamIzVnVkQzUxYVdRaU9pSTBNek0yTlRRMU55MDBZelptTFRSaU16SXRPRFF5WmkxbVpETTNPV0ZpTmpVeE1tVWlMQ0p6ZFdJaU9pSnplWE4wWlcwNmMyVnlkbWxqWldGalkyOTFiblE2WkdWbVlYVnNkRHBuYVhSb2RXSXRZV04wYVc5dWN5MXJkV0psY201bGRHVnpMWFoxYkhSeUluMC54ZnI4bGQ0dUs5WHc4TF9XUFYxcE1aZ2tzeExLbEpEeDZ5dVNWMWd5NWszWFYwOTdITXhkQmpvdEYybjdVZzZGWXUzakpvVU02U3o5WHl1ZVotQTJYb3BsaE1HSDYtZUU5UjhnWGhJUGNIejFjR1BfNU5NZ216WUlfV2k5UkNWMGdzeV9OWlRCUzUwTjVGbDl4NldiUjRzdVNVZjJqZFA0WW1wT1Z6SWIzS0hDdHdMSERWd3RXdHZ6cmtjNjVFTEVkRnVtQlduY2hmMFNIRWkyOWVBVjRUT2p0clFJeTBNSjNvTWpZY3FKZEZBcG53Zk1mendzbm5ueFBLdW5XVWxyaUJwUWV1RVFjNzZRQkZpQnk5dlNDa1k0WlN5V0tsYjFucXlFbEdhcC1pMUk1a3dkUFJTOEsxamF2U0RidGdkMGVWT2lYV1E3OG9GN3BfUFV3cXZWU0E=
kind: Secret
metadata:
  annotations:
    kubectl.kubernetes.io/last-applied-configuration: |
      {"apiVersion":"v1","data":{"extra":"YmFyCg=="},"kind":"Secret","metadata":{"annotations":{"kubernetes.io/service-account.name":"github-actions-kubernetes-vultr"},"name":"secret-github-actions-kubernetes-vultr","namespace":"default"},"type":"kubernetes.io/service-account-token"}
    kubernetes.io/service-account.name: github-actions-kubernetes-vultr
    kubernetes.io/service-account.uid: 43365457-4c6f-4b32-842f-fd379ab6512e
  creationTimestamp: "2023-01-30T10:28:26Z"
  name: secret-github-actions-kubernetes-vultr
  namespace: default
  resourceVersion: "490057"
  uid: 6b5a9dd0-2fce-4213-915c-3dc754ebd74a
type: kubernetes.io/service-account-token

Create a new GitHub action secret named KUBERNETES_SECRET in the GitHub actions secret page, and copy the above content from yaml output to the secret. You will use this KUBERNETES_SECRET later in the GitHub workflow file.

Create an image pull secret for deployment

Go to your GitHub developer settings page, then create a new GitHub token that has the permission to "read:packages", so that Kubernetes can pull the image from the GitHub container registry. Container registry later on. After having the GitHub token created, you create a secret using the command below:

$ kubectl create secret docker-registry github-container-registry --docker-server=ghcr.io --docker-username=<github-username> --docker-password=<token>

The output should show Kubernetes has successfully created a new secret named github-container-registry. You will use this secret in the Kubernetes deployment yaml file later.

Create a secret file for adding environment variables and credentials for the app

The movie management application requires environment variables for DB_HOST, DB_NAME, DB_USERNAME, DB_PASSWORD, DB_PORT to run. You need to create a secret file for defining these environment variables so that the Kubernetes deployment process will use these secret values later on when creating the Kubernetes pod. You need to encode each environment value using base64 encode method first. For example, below is the command to encode "example" value using base64:

$ echo 'example' | base64

You should see the similar result as:

ZXhhbXBsZQo=

Then create a new file named secret-as-environment-variable.yaml.

$ nano secret-as-environment-variables.yaml

Copy the following content and replace the values of secrets with values of your secrets in base64 encoded format.

apiVersion: v1
kind: Secret
metadata:
  name: mysecret
type: Opaque
data:
  DB_HOST: dnVsdHItcHJvZC0xNGMyYzEwNS1mNTI4LTQxOTktYTAwZC01MjY3NmZhMTY2NzMtdnVsdHItcHJvZC0yZDMyLnZ1bHRyZGIuY29tCg==
  DB_USERNAME: ZG9uYWxkCg==
  DB_PASSWORD: Y3VvbmdsZWRpbmgK
  DB_PORT: MTY3NTEK
  DB_NAME: bW92aWVfbWFuYWdlbWVudAo=

Then run the following command to create the secret:

$ kubectl apply -f secret-as-environment-variable.yaml

You should see the message showing Kubernetes has created the secret named mysecret.

Create a GitHub action workflow

You have successfully prepared the secrets, cluster role, and service account for Kubernetes to interact with GitHub actions. Let's create a GitHub action workflow. Go to your local project, and create a folder named .github. Inside .github folder, create a folder named workflows.

$ cd ~/Projects/movie-management-app
$ mkdir -p .github/workflows

Inside the workflows folder, you create a workflow definition file named movie-management-vultr.yaml.

$ nano movie-management-vultr.yaml

Copy the following content to it.

name: movie-management-vultr

on: push

jobs:
  test:
    name: Test
    runs-on: ubuntu-latest
    steps:
      - name: Checkout source code
        uses: actions/checkout@v3
      - name: Set up Python 3.9
        uses: actions/setup-python@v2
        with:
          python-version: 3.9 # Modify python version HERE
      #Task for installing dependencies, multi-line command
      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          python -m pip install black pytest
          if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
      - name: run unit test and integration test
        env:
          DB_HOST: ${{ secrets.DB_HOST }}
          DB_USERNAME: ${{ secrets.DB_USERNAME }}
          DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
          DB_PORT: ${{ secrets.DB_PORT }}
          DB_NAME: ${{ secrets.DB_NAME }}
        run: |
          pytest
  build:
    name: Build
    needs: test
    runs-on: ubuntu-latest
    steps:
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v1

      - name: Login to GitHub Container Registry
        uses: docker/login-action@v2
        with:
          registry: ghcr.io
          username: ${{github.actor}}
          password: ${{ secrets.GH_TOKEN }}

      - name: Build and push the Docker image
        uses: docker/build-push-action@v3
        with:
          push: true
          tags: |
            ghcr.io/cuongld2/vultr-cicd-githubactions:latest
            ghcr.io/cuongld2/vultr-cicd-githubactions:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
  deploy:
    name: Deploy
    needs: [ test, build ]
    runs-on: ubuntu-latest
    steps:
      - name: Set the Kubernetes context
        uses: azure/k8s-set-context@v2
        with:
          method: service-account
          k8s-url: https://d77dc163-b45d-436a-ab4b-e75b921581fa.vultr-k8s.com:6443
          k8s-secret: ${{ secrets.KUBERNETES_SECRET }}
      - name: Checkout source code
        uses: actions/checkout@v3
      - name: Deploy to the Kubernetes cluster
        uses: azure/k8s-deploy@v1
        with:
          namespace: default
          manifests: |
            kubernetes/deployment.yaml
            kubernetes/service.yaml
          images: |
            ghcr.io/cuongld2/vultr-cicd-githubactions:${{ github.sha }}

The GitHub action will trigger this workflow if you push new code to the repository. Inside this workflow, you have the secrets as DB_HOST, DB_NAME, DB_USERNAME, DB_PASSWORD, DB_PORT. You use these secrets when running the test before deploying it to Kubernetes. Create five more new secrets with the actual value of the movie-management database.

You also have another secret for GH_TOKEN. You need the GitHub token to push the new container image to the GitHub container registry. You must create another GitHub token with permission for repo, write:packages. Then add a new action secret named GH_TOKEN, and put the value of the GitHub token you created in it.

You already created the secret for KUBERNETES_SECRET in the step "Create a secret for the service account to store GitHub token, " so please ignore it.

Create a deployment file

Let's create a deployment file to deploy the app to Kubernetes. Go to your local project, then create a folder named kubernetes.

$ cd ~/Projects/movie-management-app
$ mkdir kubernetes

Inside the kubernetes folder, create a file named deployment.yaml.

$ nano deployment.yaml

Copy the following content to it. Remember to replace the container image ghcr.io/cuongld2/vultr-cicd-githubactions:latest with your actual value.

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: movie-management-deployment
  labels:
    app: movie-management
spec:
  replicas: 1
  selector:
    matchLabels:
      app: movie-management
  template:
    metadata:
      labels:
        app: movie-management
    spec:
      containers:
        - name: movie-management
          image: ghcr.io/cuongld2/vultr-cicd-githubactions:latest
          ports:
            - containerPort: 8084
          env:
            - name: DB_HOST
              valueFrom:
                secretKeyRef:
                  name: mysecret
                  key: DB_HOST
                  optional: false
            - name: DB_USERNAME
              valueFrom:
                secretKeyRef:
                  name: mysecret
                  key: DB_USERNAME
                  optional: false
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: mysecret
                  key: DB_PASSWORD
                  optional: false
            - name: DB_PORT
              valueFrom:
                secretKeyRef:
                  name: mysecret
                  key: DB_PORT
                  optional: false
            - name: DB_NAME
              valueFrom:
                secretKeyRef:
                  name: mysecret
                  key: DB_NAME
                  optional: false
      imagePullSecrets:
        - name: github-container-registry

Push the project code to GitHub

Create a new GitHub repository, and then you push your local project to that GitHub repository in the main branch. The workflow should automatically run with successful results for all stages: test, build, and deploy.

Check the deployed app

After the GitHub workflows is finished, open the terminal in your local machine to get the Kubernetes pod.

$ kubectl get pod

You should see similar output as:

NAME                                           READY   STATUS    RESTARTS   AGE
movie-management-deployment-5bb8f8cf74-mj9x6   1/1     Running   0          27h

The application is now up and running in Kubernetes. Let's try to interact with the app from the local environment. To do that, you need to forward the app's port inside the Kubernetes cluster to the local port using the below command. Note that you need to replace the pod name with your actual one.

$ kubectl port-forward movie-management-deployment-5bb8f8cf74-mj9x6 8084:8084

You should see the similar output as below:

Forwarding from 127.0.0.1:8084 -> 8084
Forwarding from [::1]:8084 -> 8084
Handling connection for 8084
Handling connection for 8084
Handling connection for 8084
Handling connection for 8084
Handling connection for 8084
Handling connection for 8084

Let's use the authenticate API of your app to authenticate the user to see whether the deployed app is working.

$ curl --location --request POST 'http://localhost:8084/authenticate' \
--header 'Content-Type: application/json' \
--data-raw '{
    "username": "new_user",
    "password":"12345"
}'

You should see similar output as:

{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJuZXdfdXNlciIsImV4cCI6MTY3NTQ4NTc3MX0.lH8fDhiU2E1cTkola1FAuFFAwe4tZurC5TKr7kQXHg0","token_type":"Bearer"}

The app is working as fine. You have finally completed implementing the CI/CD pipeline triggered to deploy the app to the Vultr Kubernetes cluster.

Conclusion

Through the article, you have learned about how CI/CD pipeline works and have hands-on practice deploying your application to the Vultr Kubernetes cluster with the help of GitHub actions. To learn more about deploying CI/CD pipeline to Kubernetes cluster with different examples, check out other interesting Vultr articles.