GitHub Actions CI/CD Tutorial Series — Part 4

--

tutorial banner

In Part 3 of this tutorial series, we covered the following steps:

*) Created the Dockerfile
*) Created folder and YAML file for the CI/CD part
*) Configured Slack Webhooks to add as a job on our YAML file

If you missed Part 3, you can find it here: Part 3

Continue expanding the YAML file — Yalla | يلا

Our current cicd.yml file looks like this:

name: Build & Deployment

on:
push:
branches:
- master
pull_request:
branches:
- master

jobs:
build:
name: Build Project
runs-on: ubuntu-22.04

steps:
- name: Slack Notification Ci/Cd started
uses: 8398a7/action-slack@v3.15.1
with:
status: ${{ job.status }}
fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
text: 'CI/CD started :eyes: In the Name of God the Merciful, the Compassionate / Bismillahir Rahmanir Raheem /بسم الله الرحمن الرحيم'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

Next, add the steps to check out the repository and validate Gradle Wrapper:

      - name: Checkout Repository
uses: actions/checkout@v3.5.1

- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1.0.6

By the way you can find the steps after actions/ like checkout@v3& v3wrapper-validation-action@v1here at GitHub Marketplace .

Then add the step to running the Super-Linter, which is a popular linting tool that helps identify and report code issues across multiple languages and file formats.

- name: Run Super-Linter
uses: github/super-linter@v4.10.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VALIDATE_MARKDOWN: false
VALIDATE_SQLFLUFF: false

env: This section defines the environment variables for the action:

  • GITHUB_TOKEN: The GitHub token is automatically generated by GitHub Actions and provides the action with a token to interact with the GitHub API. The token is dynamically set using ${{ secrets.GITHUB_TOKEN }}.
  • VALIDATE_MARKDOWN: By setting this variable to false, the Super-Linter will not lint Markdown files during the workflow.
  • VALIDATE_SQLFLUFF: By setting this variable to false, the Super-Linter will not run SQLFluff, a SQL linter, during the workflow.

After that add again a step to post something in the Slack channel so other people will also know that the Super-Linter check is finished:

- name: Super Linter Slack Notification
uses: 8398a7/action-slack@v3.15.1
with:
status: ${{ job.status }}
fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
text: 'Super Linter finished... :heavy_check_mark: nice / lateef / لطيف'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

The next step would be to log in on Docker Hub:

      - name: Login to Docker Hub
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}

Here we add again secrets to GitHub, so basically you need to have an account on https://hub.docker.com/ and add your username to GitHub secrets.

add DOCKER_USERNAME and DOCKER_PASSWORD to secrets

Add again a step for Slack to post the current status on the channel:

- name: Docker Hub Slack Notification
uses: 8398a7/action-slack@v3.15.1
with:
status: ${{ job.status }}
fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
text: 'Docker Hub Login... :lock: okay / tayyib / طيب'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

Now, we need a step for building and pushing a Docker image to a Docker registry (in this case, Docker Hub):

      - name: Build and push Docker image
uses: docker/build-push-action@v4.0.0
with:
push: true
tags: habibicoding/task-app-api:latest
context: .

with: This section defines the input parameters for the action:

  • push: Setting this to true enables pushing the built Docker image to the specified registry. If set to false, the image would only be built but not pushed.
  • tags: Specifies the image tags that will be assigned to the built image. In this case, the image is tagged as habibicoding/task-app-api:latest, where habibicoding is the Docker Hub username, task-app-api is the repository name, and latest is the tag for the most recent version of the image.
  • context: This parameter specifies the build context, which is the path to the directory containing the Dockerfile and other files required for building the image. In this case, the . (dot) represents the current working directory.

Time to add again a step for a Slack channel notification:

- name: Notify Docker push results
uses: 8398a7/action-slack@v3.15.1
with:
status: ${{ job.status }}
fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
text: ':white_check_mark: pushed habibicoding/task-app-api:latest to Docker Hub... https://hub.docker.com/repository/docker/habibicoding/task-app-api'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

Add now an line break and start the second job at the very left of the file like build .

Adding our second deploy job

Now we are finished with the build job so we can add our second and last job the deploy :

  deploy:
name: Deploy Project
needs: build
if: github.event_name == 'push'
runs-on: ubuntu-22.04
steps:

needs: Specifies that this job depends on the successful completion of the build job. The deploy job will only be executed if the build job has successfully completed.

if: This condition specifies that the deploy job will only be executed if the triggering event is a 'push' to the repository. If the workflow is triggered by any other event (e.g., pull request), the deploy job will be skipped.

runs-on: Specifies the type of runner on which the job will be executed. In this case, the job will run on an Ubuntu 22.04 runner.

This job configuration ensures that the deployment process only occurs when the build job completes successfully, and the triggering event is a push to the repository. The job will run on an Ubuntu 22.04 runner, providing a consistent environment for executing the deployment steps.

Next, add again a Slack step for notifying our channel:

- name: Notify deployment started
uses: 8398a7/action-slack@v3.15.1
with:
status: ${{ job.status }}
fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
text: 'Linode deployment started... :crossed_fingers: so God will / in shaAllah / ان شاء الله خير'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

After that set up your SSH agent with webfactory :

- name: Setup SSH agent
uses: webfactory/ssh-agent@v0.7.0
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

When the workflow reaches this step, it will set up an SSH agent and load the provided private key into it. This allows the workflow to authenticate with the Linode remote server using the corresponding public key, which will be added to the authorized_keys file on the Linode Ubuntu instance. This step is particularly useful for securely deploying code, managing infrastructure, or performing other tasks that require SSH authentication in a GitHub Actions workflow.

Then create Ed25519 SSH key pair and store them in a specific file on your locale computer. You will use the private key on the GitHub Actions pipeline and the public key on the Linode Ubuntu instance.

Just create first a new folder:

 mkdir pipeline-linode
new folder for ssh key

Enter the command to generate the Ed25519 SSH inside the pipeline-linode folder:

ssh-keygen -t ed25519 -f ~/pipeline-linode/pipeline-linode -C "habibicoding@aol.com"
generate Ed25519 SSH key pair

VERY VERY IMPORTANT DON’T ENTER A PASSPHRASE FOR THIS KEY GENERATION!!!

If you type a passphrase the GitHub Actions pipeline will not be able to connect to the Linode Ubuntu instance, because it can’t give at connection time the passphrase to our server.

JUST PRESS ENTER AT “Enter passphrase (empty for no passphrase):” & “Enter same passphrase again:”

just press ENTER at passphrase

Time to navigate to your pipeline-linode folder and list all files:

cd pipeline-linode && ls -la
look at the generated file

First copy the pipeline-linode.pub file inside your authorized_keys file of your Linode Ubuntu instance:

pbcopy < pipeline-linode.pub
copy public key

Second use SSH again to connect to your server:

ssh {your-user}@{your-linode-ip} -p 1022
connect to your Linode Ubuntu instance

Navigate to your .ssh/ folder and open with sudo vim/nano the authorized_keys file:

cd .ssh/
sudo vim authorized_keys
edit authorized_keys

After that add in the file a new line and paste your public key:

add public key

Save and close the file, then check if it is there:

cat authorized_keys
check if public got added

Logout from your server and you should be again in your locale pipeline-linode folder and list all files again:

exit
ls -la
exit and list again all files

Now, we need to copy the private key, which has only the name pipeline-linode:

pbcopy < pipeline-linode
copy private key

Open in your browser again GitHub and go again to the settings section of your repository and add another secret:

add a new secret

IMPORTANT the name of the secret needs exactly to be SSH_PRIVATE_KEY because we named it so in the pipeline:

add secret SSH_PRIVATE_KEY

After pressing “Add secret” you should see the new secret in your list:

SSH_PRIVATE_KEY in list of secrets

Jump back again now to the cicd.yml file and add the step to deploy our application to the server:

    - name: Deploy to Droplet
run: |
ssh -o StrictHostKeyChecking=no -p ${{ secrets.DROPLET_PORT }} ${{ secrets.DEPLOYMENT_USER }}@${{ secrets.DEPLOYMENT_HOST }} "\
docker-compose pull && \
docker-compose up -d && \
docker system prune -af"

run: This section contains a series of shell commands that will be executed in the runner's environment. The commands are executed using a single SSH command, connecting to the remote server with specified options and credentials.

Here’s an explanation of the SSH command and its components:

  • ssh: The SSH command, which establishes a secure connection to the remote server.
  • -o StrictHostKeyChecking=no: This option disables strict host key checking, allowing the connection even if the remote server's public key is not in the local known_hosts file. This can be useful for automation but may pose a security risk as it exposes the connection to potential man-in-the-middle attacks.
  • -p ${{ secrets.DROPLET_PORT }}: Specifies the port to use when connecting to the remote server. The port number is stored as a secret in the repository settings.
  • ${{ secrets.DEPLOYMENT_USER }}@${{ secrets.DEPLOYMENT_HOST }}: Specifies the username and hostname (or IP address) of the remote server. Both values are stored as secrets in the repository settings.

The SSH command is followed by a series of commands enclosed in double quotes and separated by backslashes. These commands will be executed on the remote server:

  1. docker-compose pull: Pulls the latest version of the Docker images specified in the docker-compose.yml file.
  2. docker-compose up -d: Starts or updates the services defined in the docker-compose.yml file in detached mode (running in the background).
  3. docker system prune -af: Cleans up unused Docker objects, such as stopped containers, unused networks, and dangling images. The -a flag removes all unused images, not just dangling ones, and the -f flag forces the cleanup without prompting for confirmation.

With that, we conclude the fourth part of this tutorial series. If you found it useful and informative, give it a clap. Here is Part 5.

Don’t forget to check out the video version of this article on our YouTube channel at https://www.youtube.com/@habibicoding.

--

--