Create a CI/CD Pipeline with GitHub Actions

While working as a team, we use EC2 as a test server. But there were two problems we faced.
  • We had to access the server using ssh key and git pull whenever we pushed new commits.
  • "It worked on my machine but..."
  • Guess it's the time to learn how to set up CI/CD pipeline. But first, what is CI/CD?

    CI/CD is a method to frequently deliver apps to customers by introducing automation into the stages of app development. The main concepts attributed to CI/CD are continuous integration, continuous delivery, and continuous deployment. CI/CD is a solution to the problems integrating new code can cause for development and operations teams (AKA "integration hell").
    source: What is CI/CD?

    The pipeline makes this process seamless.
    Before you begin, create an EC2 instance. Then, let's deploy a FastAPI application using docker and github action(It doesn't have to be a FastAPI application. Any backend application will be fine). There are many ways to do this. I'll show you two of them.
    Requirements
  • FastAPI Application
  • Docker
  • docker-compose
  • GitHub
  • AWS EC2
  • Dockerizing the Application
    With docker-compose, you can define and run multi-container Docker applications. There are two services here.
    # docker-compose.yaml
    version: "3.3"
    services:
      mysqldb:
        image: "mysql"
        ports:
          - "3306:3306"
        volumes:
          - data:/data/db
          - ./env/mysql.env:/env/mysql.env
        env_file:
          - ./env/mysql.env
      app:
        build: {the path where your Dockerfile is}
        restart: always
        ports:
          - "8000:8000"
        volumes:
          - ./env/.env:/env/.env
        env_file:
          - ./env/.env
        links:
          - mysqldb
        depends_on:
          - mysqldb
    
    volumes:
      data:
    First one is MySQL database. With image: "mysql", you can pull MySQL image from docker hub, and you don't have to install MySQL in your EC2 machine. If you want to use a specific version of it, image: "mysql:{version}".
    We are setting up a CI/CD pipeline. If the data in the database is lost whenever we deploy, it wouldn't be a seamless experience, right? To persist the data, use named volumes.
    mysqldb:
      image: "mysql"
      ports:
        - "3306:3306"
      volumes:
        - data:/data/db
        - ./env/mysql.env:/env/mysql.env
      env_file:
        - ./env/mysql.env
    ...
    volumes:
        data:
    And the next one is your application. Make sure that build: is pointing the directory where Dockerfile is.
    app:
      build: ./
      restart: always
      ports:
        - "8000:8000"
      volumes:
        - ./env/.env:/env/.env
      env_file:
        - ./env/.env
      links:
        - mysqldb
      depends_on:
        - mysqldb
    Due to security issue, it is not a good idea to commit your configuration file to github. However, we still want to automate deployment job with github action. If we gitignore the configuration file, the application cannot start because it can't find and import the file.
    To avoid this problem, git pull from your EC2 machine, create configuration file in the machine(with config variables) as well as in the container(empty). You can share data with the files in your container and the machine that it is running with Bind Mounts.
    # Dockerfile
    FROM python:3.9
    
    WORKDIR /
    
    ENV DOCKERIZE_VERSION v0.2.0
    
    RUN wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz \  
        && tar -C /usr/local/bin -xzvf dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz
    
    COPY ./requirements.txt /requirements.txt
    
    RUN pip install --upgrade pip
    
    RUN pip install --no-cache-dir --upgrade -r /requirements.txt
    
    COPY . .
    
    RUN touch env/.env
    
    RUN touch env/mysql.env
    
    RUN chmod +x docker-entrypoint.sh
    
    ENTRYPOINT ./docker-entrypoint.sh
    
    EXPOSE 8000
    
    CMD ["python", "app/main.py"]
    # docker-compose.yaml
    ...
    volumes:
        ...
      - ./env/mysql.env:/env/mysql.env
    ...
    volumes:
      - ./env/.env:/env/.env
    ...
    
    # Dockerfile
    ...
    RUN touch env/.env # creates env file in container
    
    RUN touch env/mysql.env # creates env file in container
    ...

    Note that I'm trying to create development environment. Bind Mounts are not meant to be used in production.

    These are my environment files in the EC2 machine.
    # env/.env
    JWT_ALGORITHM=****
    JWT_SECRET_KEY=****
    SQLALCHEMY_DATABASE_URL=mysql+pymysql://{MYSQL_USER}:{MYSQL_PASSWORD}@mysqldb:3306/{MYSQL_DATABASE}
    
    # env/mysql.env
    MYSQL_USER=****
    MYSQL_PASSWORD=****
    MYSQL_ROOT_PASSWORD=****
    MYSQL_DATABASE=****
    Since there's depends_on: mysqldb in docker-compose.yaml, it automatically build a network for the containers. Therefore we can define database url using the name defined in the docker-compose.yaml(mysqldb).
    # docker-entrypoint.sh
    dockerize -wait tcp://mysqldb:3306  -timeout 20s
    
    echo "Start server"
    alembic upgrade head
    python /app/main.py
    Creating GitHub Workflow
    First, clone your repository in your EC2 machine and create env files.
    Then, let's set actions secrets. In your repository, go to Settings > Secrets.
    By clicking New repository secret, you can define environment variables which are using during github actions.
    Add your application environment values and EC2 credentials.
    And for AWS_EC2_PEM, put this.
    Click set up a workflow your self and create Github workflow.
    # .github/workflows/main.yml
    name: CI/CD Docker
    
    on:
      push:
        branches:
          - main
      pull_request:
        branches:
          - main
    
    jobs:
      Integration:
        timeout-minutes: 3
        runs-on: ubuntu-latest
        steps:
          - name: Checkout
            uses: actions/checkout@v1
          - name: Create env file
            env:
              JWT_ALGORITHM: ${{ secrets.JWT_ALGORITHM }}
              JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }}
              SQLALCHEMY_DATABASE_URL: ${{ secrets.SQLALCHEMY_DATABASE_URL }}
              MYSQL_USER: ${{ secrets.MYSQL_USER }}
              MYSQL_PASSWORD: ${{ secrets.MYSQL_PASSWORD }}
              MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
              MYSQL_DATABASE: ${{ secrets.MYSQL_DATABASE }}
            run: |
              mkdir env
              touch ./env/.env
              echo JWT_ALGORITHM="$JWT_ALGORITHM" >> ./env/.env
              echo JWT_SECRET_KEY="$JWT_SECRET_KEY" >> ./env/.env
              echo SQLALCHEMY_DATABASE_URL="$SQLALCHEMY_DATABASE_URL" >> ./env/.env
              ls -a
              cat env/.env
              touch ./env/mysql.env
              echo MYSQL_USER="$MYSQL_USER" >> ./env/mysql.env
              echo MYSQL_PASSWORD="$MYSQL_PASSWORD" >> ./env/mysql.env
              echo MYSQL_ROOT_PASSWORD="$MYSQL_ROOT_PASSWORD" >> ./env/mysql.env
              echo MYSQL_DATABASE="$MYSQL_DATABASE" >> ./env/mysql.env
              ls -a
              cat env/mysql.env
            shell: bash
          - name: Start containers
            run: docker-compose up -d
    
      Deployment:
        needs: Integration
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v2
          - name: Git pull
            env:
              AWS_EC2_PEM: ${{ secrets.AWS_EC2_PEM  }}
              AWS_EC2_PUBLIC_IP: ${{ secrets.AWS_EC2_PUBLIC_IP  }}
              AWS_EC2_USERNAME: ${{ secrets.AWS_EC2_USERNAME  }}
            run: |
              pwd
              echo "$AWS_EC2_PEM" > private_key && chmod 600 private_key
              ssh -o StrictHostKeyChecking=no -i private_key ${AWS_EC2_USERNAME}@${AWS_EC2_PUBLIC_IP} '
                cd {/path/to/your/project/directory} &&
                git checkout main &&
                git fetch --all &&
                git reset --hard origin/main &&
                git pull origin main &&
                docker-compose up -d --build
              '
    Here, I will use GHCR(GitHub Container Registry). A container registry is a repository(or a collection of repositories), used to store container images.
    Requirements
  • FastAPI application
  • Docker
  • AWS EC2
  • AWS RDS
  • GitHub
  • Dockerizing Application
    This time I'm going to use RDS instead of MySQL container, so it is much more simple to dockerize the app compared to the previous one.
    FROM python:3.9
    
    WORKDIR /
    
    COPY ./requirements.txt /requirements.txt
    
    RUN pip install --no-cache-dir --upgrade -r /requirements.txt
    
    COPY . .
    
    EXPOSE 8000
    
    RUN touch .env
    
    CMD ["python", "app/main.py"]
    Creating GitHub Workflow
    Generate a token for github action. Click your profile and go to Settings > Developer settings > Personal access tokens.
    Among scopes, select repo, workflow, and packages.
    Copy the token, paste it to actions secrets, and add other secrets.
    In the github repository, go to Settings > Actions > Runners, and click New self-hosted runner.
    Choose the right OS of your EC2 machine, and in the machine, do as the guidance says.
    Before running it using ./run.sh command, let's configure workflow file.
    name: CI/CD Docker
    
    on:
      push:
        branches:
          - main
    env:
      DOCKER_IMAGE: ghcr.io/ninahwang/cicd_2 # this should be lower case !
      VERSION: ${{ github.sha }}
    
    jobs:
      build:
        name: Build
        runs-on: ubuntu-latest
        steps:
          - name: Check out source code
            uses: actions/checkout@v2
          - name: Set up docker buildx
            id: buildx
            uses: docker/setup-buildx-action@v1
          - name: Cache docker layers
            uses: actions/cache@v2
            with:
              path: /tmp/.buildx-cache
              key: ${{ runner.os }}-buildx-${{ env.VERSION }}
              restore-keys: |
                ${{ runner.os }}-buildx-
          - name: Login
            uses: docker/login-action@v1
            with:
              registry: ghcr.io
              username: ${{ github.actor }}
              password: ${{ secrets.TOKEN }}
          - name: Build and push
            id: docker_build
            uses: docker/build-push-action@v2
            with:
              builder: ${{ steps.buildx.outputs.name }}
              push: ${{ github.event_name != 'pull_request' }}
              tags: ${{ env.DOCKER_IMAGE }}:${{ env.VERSION }}
    
      deploy:
        needs: build
        name: Deploy
        runs-on: [self-hosted]
        steps:
          - name: Login to ghcr
            uses: docker/login-action@v1
            with:
              registry: ghcr.io
              username: ${{ github.actor }}
              password: ${{ secrets.TOKEN }}
          - name: Create .env file
            env:
              JWT_ALGORITHM: ${{ secrets.JWT_ALGORITHM }}
              JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }}
              SQLALCHEMY_DATABASE_URL: ${{ secrets.SQLALCHEMY_DATABASE_URL }}
            run: |
              touch .env
              echo JWT_ALGORITHM="$JWT_ALGORITHM" >> .env
              echo JWT_SECRET_KEY="$JWT_SECRET_KEY" >> .env
              echo SQLALCHEMY_DATABASE_URL="$SQLALCHEMY_DATABASE_URL" >> .env
            shell: bash
          - name: Docker run
            run: |
              docker ps -q --filter "name=cicd_2" | grep -q . && docker stop cicd_2 && docker rm -fv cicd_2
              docker run -p 8000:8000 -d -v "$(pwd)/.env:/.env" --restart always --name cicd_2 ${{ env.DOCKER_IMAGE }}:${{ env.VERSION }}
    Now run run.sh.

    30

    This website collects cookies to deliver better user experience

    Create a CI/CD Pipeline with GitHub Actions