In this tutorial, I’ll show you how to build CI/CD with GitHub Actions and Docker-compose and deploy it to DigitalOcean.
In the first part of this tutorial, I set up a Client project based on React, and a Server project based on Node JS.
Dockerized them with docker-compose(development/production), and set up Nginx as a proxy server for the Client and Server(API) apps.
Setting up GitHub Action
So, let’s create a new Workflow for our CI/CD.
Open the Actions tab in your GitHub repo and press the “New workflow” button.

On this page, you will find different workflows for any kind of thing.

Search simple workflow and press Configure.

You’ll see the example of the workflow with the default name and simple job.
Step 1: Client Job

First, change the default name to CICD.yml.
Then replace the simple workflow with mine.
# This is a basic workflow to help you get started with Actions
name: CICD
# Controls when the workflow will run
on:
push:
branches: [ main ]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
test:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v3
# Set up the node 16 version
- name: Set up node
uses: actions/setup-node@v3
with:
node-version: 16
# Run Test script
# We need to go to the client folder as in the root of the project we have only Client and API folders. Then install npm packages and run tests.
- name: Test Client
run: |
cd client
npm install
npm run test
Before we run this workflow we need to make some changes to be able to run the client and server tests from the workflow.
As you remember when we run client tests we see the menu and we have to press “a” to run all tests.
But in our case, we need to run tests without this menu. For this, we need to add CI=true to the test script.
Let’s go to the project on your pk and add this parameter.
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "CI=true react-scripts test",
"eject": "react-scripts eject"
},
Now let’s run client tests.

Okay, now we don’t have that menu.
Let’s also add this parameter to the server test script and commit your changes and push it to GitHub.
"scripts": {
"start": "node src/app.js",
"dev": "nodemon -L src/app.js",
"test": "CI=true jest --config ./jest.config.json"
},
After that let’s go to the GitHub workflow and commit it.

Now our workflow will start automatically or we can start it manually.

After our workflow started you can see its status.

After the finish of the process, you’ll see a green success mark.
If you have a problem with this step, you need to make sure that you made the same changes as me.
You also can reach me in the comments or on Instagram.
Let’s change out a test to make sure that our workflow works.
To do this open App.test.js in your local project and change the text, I added “learn react 11” to getByText.
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react 11/i);
expect(linkElement).toBeInTheDocument();
});
Okay, commit and push your changes.
Then open GitHub workflow in the browser and go back to the previous page.
You’ll see that your workflow has already started, but it must finish with a failure status.
If you open the workflow details you’ll see that your test failed because of getByText didn’t find the text (“learn react 11”) on the page.

Okay, fix the test and commit it.
Step 2: Api (server) Job
So, let’s add a script for running server tests.
Edit workflow and add the following script to the test job.
- name: Test Client
...
# Go to the api folder, install packages and tun tests
- name: Test Api
run: |
cd api
npm install
npm run test
To edit your workflow from GitHub go back to Action, select CICD workflow on the left menu, and press CICD.yml

You can also edit the workflow in your IDE.
To do this pull your code from GitHub, then you’ll see the .github folder, inside this folder you’ll find your workflow.

I prefer to make changes right on the GitHub page.
After you commit your changes let’s run the workflow one more time to check the server tests.

Okay, we’ve finished the test job, the next step is a “build“ job.
Setting up DigitalOcean
DigitalOcean is an American technology company and cloud service provider.
You can find all the services on the official website.
Step 1: Create a new Project
If you just registered you’ll have the Started Project. I’ll create a new project as I have another project.

Step 2: Create a Droplet
Droplets are flexible Linux-based virtual machines that run on top of virtualized hardware. Each Droplet you create is a new cloud server you can use.
Let’s create a new droplet, to do this press on Get Started with a Droplet

Choose Region: you need to choose the region, I selected Frankfurt.
Choose an image: in the marketplace tab, select Docker on the Ubuntu. We need a virtual machine with preinstalled docker as we’ll run our docker container with apps.

Choose Size: for this tutorial, I chose Basic Type and Regular CPU.

Choose Authentication Method: I prefer the ssh method.
For this select SSH Key and press Add SSH Key.

In the opened modal you need to add your public ssh and name.
To copy your ssh key to the clipboard run this code in the terminal.
pbcopy < ~/.ssh/id_rsa.pub

After you set up the ssh press Create Droplet.
The creation process can take more than a minute.

Great, now you can see your new droplet.
Step 3: Setting up Docker Container Registry
Since we will be using Docker, we’ll need a Docker Registry to store our images.

To create it you need to type a unique name, select the region, and plan.

I chose the Basic plan because for this tutorial we need 3 repo for Client, Server, and Nginx images.
After creating you’ll see Registry Page.

Step 4: Generate API Token
Open the API page and generate a new Access Token, we need it to be able to push Docker images from GitHub Workflow.

In the opened modal you need to type the name and select Expiration.
For this tutorial, I selected 90 days, but you can choose No Expiry.

Now you need to copy this token somewhere because after refreshing the page you won’t be able to copy it.

GitHub Actions Secrets
Encrypted secrets allow you to store sensitive information in your repository environments.
To add the secrets open the Repo Settings > Secrets > Actions and press “new repository secret“.

DIGITALOCEAN_ACCESS_TOKEN – it is a DigitalOcean API Token we created earlier.
For example dop_v1_3d57ae57f8dd77b020c46a0ed45ca3bba3ea3d70bbe6d9239e5028ba0a63008f
HOST – it is a Droplet host (IP).

SSHKEY – it is a private ssh key (NOT PUBLIC). To copy it run the following command in the terminal.
pbcopy < ~/.ssh/id_rsa
PASSPHRASE – it is the password of your ssh.
USERNAME – it is the default name (root).
GitHub Actions (Build and Push)
So, we’ve already added secrets and now we can create a new job for building Docker images with our apps and push them to the DigitalOcean Registry.
Step 1: Setting up environments
First of all, we need to add this environment before the jobs section in CICD.yml and replace REGISTRY with your registry URL
env:
REGISTRY: "registry.digitalocean.com/cicd"
CLIENT_IMAGE: "client"
API_IMAGE: "api"
GATEWAY_IMAGE: "gateway"
Step 2: Add build_and_push Job
Let’s create a new job.
This job will also be run on the Ubuntu Machine.
In the needs field, I added a test job, which means that this job will wait for the test job.
And I also added a script for checkout of the repo.
build_and_push:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout the repo
uses: actions/checkout@v3
Step 2: Build Containers
So now let’s add the next command to make a new build using docker-compose.
- name: Build container image
run: docker-compose -f docker-compose.yml build
Step 3: Add Tags for Containers
Then, we need to add a tag for each image. You can find more information about tags on the official website.
- name: Add Tag
run: |
docker tag $CLIENT_IMAGE $REGISTRY/$CLIENT_IMAGE:latest
docker tag $API_IMAGE $REGISTRY/$API_IMAGE:latest
docker tag $GATEWAY_IMAGE $REGISTRY/$GATEWAY_IMAGE:latest
Step 4: Connect to the Registry
In the next step, we need to connect to DigitalOcean Registry.
To do this I’ll use doctl package with Access Token in the params, and after that, we can log in to the Registry.
- name: Install doctl
uses: digitalocean/action-doctl@v2
with:
token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
- name: Log in to DigitalOcean Registry
run: doctl registry login --expiry-seconds 600
Step 5: Push Images to Registry
In the previous step, we logged in to the Registry and now we just need to push our images there.
- name: Push images to DigitalOcean
run: |
docker push $REGISTRY/$CLIENT_IMAGE:latest
docker push $REGISTRY/$API_IMAGE:latest
docker push $REGISTRY/$GATEWAY_IMAGE:latest
Full Job
build_and_push:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout the repo
uses: actions/checkout@v3
- name: Build container image
run: docker-compose -f docker-compose.yml build
- name: Add Tag
run: |
docker tag $CLIENT_IMAGE $REGISTRY/$CLIENT_IMAGE:latest
docker tag $API_IMAGE $REGISTRY/$API_IMAGE:latest
docker tag $GATEWAY_IMAGE $REGISTRY/$GATEWAY_IMAGE:latest
- name: Install doctl
uses: digitalocean/action-doctl@v2
with:
token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
- name: Log in to DigitalOcean Registry
run: doctl registry login --expiry-seconds 600
- name: Push images to DigitalOcean
run: |
docker push $REGISTRY/$CLIENT_IMAGE:latest
docker push $REGISTRY/$API_IMAGE:latest
docker push $REGISTRY/$GATEWAY_IMAGE:latest
Okay, now let’s commit the workflow and run it.
If everything is okay you’ll see your images on the Registry page.

GitHub Actions (Deploy to Droplet)
Now it remains to deploy our application to DigitalOcean Droplet.
Step 1: Add docker-compose.ci.yml
To deploy to Droplet we need to create a docker-compose.ci.yml in the root of the project.
We need it to pull images from the Registry and run containers in the droplet.
version: "3.8"
services:
nginx:
container_name: gateway
image: registry.digitalocean.com/cicd/gateway:latest
ports:
- "80:80"
restart: always
client:
container_name: client
image: registry.digitalocean.com/cicd/client:latest
restart: always
api:
container_name: api
image: registry.digitalocean.com/cicd/api:latest
restart: always
In this file, we don’t need to pass build settings as we need them only to pull and run our containers.
We also need to replace the image name with a full path to our Image.
And after that commit and push this file to GitHub.
Step 2: Add deploy Job
So, let’s create a new job for deploying our images to Droplet.
For now, you can comment on the previous jobs to test only this job.
deploy:
# needs: build_and_push
runs-on: ubuntu-latest
steps:
- name: "Checkout repository"
uses: actions/checkout@v3
I also commented “needs” field because I commented on all previous jobs.
Step 3: Copy docker-compose.ci.yml to the Droplet
To use our docker-compose config we need to copy it to the Droplet.
To do this I’ll use the “SCP-action” package.
This package needs our secrets to connect to the Droplet via ssh.
- name: Copy file via ssh key
uses: appleboy/scp-action@master
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
key: ${{ secrets.SSHKEY }}
passphrase: ${{ secrets.PASSPHRASE }}
source: "docker-compose.ci.yml"
target: "./"
Commit these changes and run the Workflow.

If everything is okay we can connect to the Droplet via ssh to check it.
Run the following command in the terminal. Don’t forget to replace 165.22.95.194 with your host.
ssh root@165.22.95.194
If you connect to Droplet the first time you’ll see this message, just type yes.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Then you need to type your ssh password.
Enter passphrase for key '/Users/artem/.ssh/id_rsa':
If you entered the right host and password you’ll see the next screen inside your terminal.

Now let’s check if our docker-compose.ci.yml exists in the Droplet. To see all files run ls in the terminal.

Yes, now you can see this file.
Step 4: Install docker-compose to DigitalOcean Droplet
By default, the Droplet doesn’t have a docker-compose package. To make sure, run docker-compose -v in the terminal, and you’ll see that docker-compose isn’t installed yet.
root@docker-ubuntu-s-1vcpu-1gb-fra1-01:~# docker-compose -v
Command 'docker-compose' not found, but can be installed with:
snap install docker # version 20.10.17, or
apt install docker-compose # version 1.29.2-1
See 'snap info docker' for additional versions.
root@docker-ubuntu-s-1vcpu-1gb-fra1-01:~#
To install it you need to run the following commands in the Droplet terminal. You can visit the official website.
sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
After that let’s check the docker-compose version. Run docker-compose -v one more time.

Yes, it works. As you can see now we have the 1.29.2 version.
docker-compose version 1.29.2, build 5becea4c
Now let’s exit from our Droplet. To exit type exit in the terminal and press enter.
Step 5: Deploy
So, we’ve completed all the previous steps and now we can start deploying the application.
To do this I’ll use “ssh-action” package. This package also requires our secrets as it’ll make a connection to the Droplet by ssh.
Add the following code to the deploy job.
- name: Deploy to Digital Ocean droplet via SSH action
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
key: ${{ secrets.SSHKEY }}
passphrase: ${{ secrets.PASSPHRASE }}
envs: REGISTRY,{{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
script: |
# Login to registry
docker login -u ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} -p ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} registry.digitalocean.com
# Stop old containers
docker stop $(docker ps -a -q)
# Remove old containers
docker rm $(docker ps -a -q)
# Pull our images from Registry
docker-compose -f docker-compose.ci.yml -p prod pull
# Run containers
docker-compose -f docker-compose.ci.yml -p prod up -d
And let’s commit the workflow file and run it.

if you did everything right you can open your host(for example http://165.22.95.194) in a browser.

And Server app. host/api/test.

Okay, it works.
Step 6: Use dynamic Registry in docker-compose.ci.yml
I don’t want to change the docker-compose file if we changed our Registry.
To do this open the docker-compose.ci.yml file in your IDE and change the registry and image versions.
version: "3.8"
services:
nginx:
container_name: gateway
image: ${REGISTRY}/gateway:${GATEWAY_VERSION:-latest}
ports:
- "80:80"
restart: always
client:
container_name: client
image: ${REGISTRY}/client:${CLIENT_VERSION:-latest}
restart: always
api:
container_name: api
image: ${REGISTRY}/api:${API_VERSION:-latest}
restart: always
Then, commit the changes.
And now we need to add .env file with Registry and Version.
You can add this file to the root of the project and copy it to the Droplet like docker-compose.ci.yml.
Or you can create it right in GitHub Workflow. I prefer to create it in GitHub Workflow.
So, let’s add a creating file script at the top of the script.
- name: Deploy to Digital Ocean droplet via SSH action
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
key: ${{ secrets.SSHKEY }}
passphrase: ${{ secrets.PASSPHRASE }}
envs: REGISTRY,{{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
script: |
touch .env
echo REGISTRY=$REGISTRY > .env
echo GATEWAY_VERSION=latest >> .env
echo CLIENT_VERSION=latest >> .env
echo API_VERSION=latest >> .env
# Login to registry
docker login -u ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} -p ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} registry.digitalocean.com
# Stop old containers
docker stop $(docker ps -a -q)
# Remove old containers
docker rm $(docker ps -a -q)
# Pull our images from Registry
docker-compose -f docker-compose.ci.yml -p prod pull
# Run containers
docker-compose -f docker-compose.ci.yml -p prod up -d
Commit the Workflow and check if it works correctly.
Step 7: Garbage Collection
If you open the Registry page you’ll see that you have old images, without tags and some build garbage.

To clean it you can press the Empty garbage button, but I want to make it automatic when we make a new build.
To do this let’s add a new step at the bottom of the build_and_push job.
- name: Run garbage collection
run: doctl registry garbage-collection start --force --include-untagged-manifests
This script will run Garbage Collection and remove untagged images.
Full Workflow file
# This is a basic workflow to help you get started with Actions
name: CICD
# Controls when the workflow will run
on:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
env:
REGISTRY: "registry.digitalocean.com/cicd"
CLIENT_IMAGE: "client"
API_IMAGE: "api"
GATEWAY_IMAGE: "gateway"
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
test:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v3
- name: Set up node
uses: actions/setup-node@v3
with:
node-version: 16
- name: Test Client
run: |
cd client
npm install
npm run test
- name: Test Api
run: |
cd api
npm install
npm run test
build_and_push:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout the repo
uses: actions/checkout@v3
- name: Build container image
run: docker-compose -f docker-compose.yml build
- name: Add Tag
run: |
docker tag $CLIENT_IMAGE $REGISTRY/$CLIENT_IMAGE:latest
docker tag $API_IMAGE $REGISTRY/$API_IMAGE:latest
docker tag $GATEWAY_IMAGE $REGISTRY/$GATEWAY_IMAGE:latest
- name: Install doctl
uses: digitalocean/action-doctl@v2
with:
token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
- name: Log in to DigitalOcean Registry
run: doctl registry login --expiry-seconds 600
- name: Push images to DigitalOcean
run: |
docker push $REGISTRY/$CLIENT_IMAGE:latest
docker push $REGISTRY/$API_IMAGE:latest
docker push $REGISTRY/$GATEWAY_IMAGE:latest
- name: Run garbage collection
run: doctl registry garbage-collection start --force --include-untagged-manifests
deploy:
needs: build_and_push
runs-on: ubuntu-latest
steps:
- name: "Checkout repository"
uses: actions/checkout@v3
- name: Copy file via ssh key
uses: appleboy/scp-action@master
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
key: ${{ secrets.SSHKEY }}
passphrase: ${{ secrets.PASSPHRASE }}
source: "docker-compose.ci.yml"
target: "./"
- name: Deploy to Digital Ocean droplet via SSH action
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
key: ${{ secrets.SSHKEY }}
passphrase: ${{ secrets.PASSPHRASE }}
envs: REGISTRY,{{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
script: |
touch .env
echo REGISTRY=$REGISTRY > .env
echo GATEWAY_VERSION=latest >> .env
echo CLIENT_VERSION=latest >> .env
echo API_VERSION=latest >> .env
# Login to registry
docker login -u ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} -p ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} registry.digitalocean.com
docker stop $(docker ps -a -q)
docker rm $(docker ps -a -q)
sudo docker-compose -f docker-compose.ci.yml -p prod pull
sudo docker-compose -f docker-compose.ci.yml -p prod up -d
So now, we need to uncomment our jobs, commit all changes and run our Workflow.

After the process is finished, open the Registry page.
As you can see our Garbage Collection was started.

That’s all, if you have some problems with this step please reach out on comments or Instagram.
Thank you for reading 🙂
