Home DevOping - 4
Post
Cancel

DevOping - 4

Devoping 4: Docker

This is not intended to be a guide. I’m sure that everything explained here can be done in a better/easier/more efficient way. Here, I will explain the whole learning process and the solutions I found that could solve my problems.

Untitled

At this point we have a functional frontend and backend with a mySQL database that allows us to have persistance. It works, but in localhost. The idea is to make the app portable and make it as easy as possible to run it everywhere.

We can create Docker images that contain all the necessary dependencies required by our application. This means that the host machine, which runs the application, only needs to have Docker installed. These images serve as templates and can be used to create containers. Containers are isolated environments that host the application along with all its dependencies, ensuring consistent behavior across different environments.

Creating Docker images

If we want to create a docker image, we need a Dockerfile. A Dockerfile is a script that dictates how an image should be built and assembled. Since I have 3 components (database, backend and frontend), I want to create three docker images.

Database Dockerfile

This one is really easy since we only need a docker image with a mySQL database. However, we need the database to be initialized. If you remember from DevOping - 2, we created a .sql file that was executed when the sql server was initialized. We can also use this script to initialize the database in our docker image.

When creating a Dockerfile, we can use base Dockerimages and slightly modify them. There is an existing sql image so will use it. The Dockerfile looks like this:

1
2
3
4
5
6
7
8
9
10
11
#BD Dockerfile

FROM mysql:latest

ENV MYSQL_ROOT_PASSWORD= XXXXXX
ENV MYSQL_DATABASE=restaurant_app
ENV MYSQL_USER=restaurantapp
ENV MYSQL_PASSWORD=XXXXXX

#We copy the SQL initialization file into the container
COPY init.sql /docker-entrypoint-initdb.d/

The FROM keyword defines the base image that we will use, in this case the latest version of the mysql image. Then we use the ENV keywords to define some environment variables. This variables are used in the container to define specific passwords/usernames and databases.

Last but not least, we copy the init.sql file inside the container. This doesn’t execute the script, but it makes it accessible inside the container. Since we copy the script inside the docker-entrypoint-initdb.d/ this script will be executed when the docker container starts.

Now that we have a Dockerfile, to build a docker image we just need to execute (in the same directory as the Dockerfile): docker build -t mysql-image .. This will create a docker image called mysql-image.

If we want to execute this image and create a running container, we just need to execute this command:

1
docker run -d -p 3306:3306 --name mysql-container -v mysql-data:/var/lib/mysql mysql-image

Let’s breakdown this command.

The -p 3306:3306 maps the 3306 port from the host with the port 3306 from the docker container.

The -v mysql-data:/var/lib/mysql creates a volume on the host named mysql-data that will allow us to obtain data persistence on the host, even if the containers stop working.

Then, we specify the docker image that we want to execute, in this case is the mysql-image that we just created.

Backend Dockerfile

This Dockerfile is also super simple:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Use a Go image as the base
FROM golang:1.22 AS builder

# Set the working directory inside the container
WORKDIR /app

# Copy the application's source code to the container
COPY . .

# Compile the Go application
RUN go mod tidy
RUN go build -o main ./cmd

# Make the main binary executable
RUN chmod +x main

# Set the command to run the executable
CMD ["./main"]

We can also use the golang base image that contains all the needed dependencies. What this script does is:

  • Copy all the source code inside the container
  • Build the application using the go build command
  • Execute the main file (which is the backend application) that was created during the go build command.

Frontend Dockerfile

This one is a bit more complex:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# Stage 1: Build the React app
FROM node:14-alpine AS build

# Set the working directory inside the container
WORKDIR /app

# Copy package.json and package-lock.json
COPY package.json package-lock.json ./

# Install dependencies
RUN npm install

# Copy the entire application
COPY . .

# Build the React app
RUN npm run build

# Stage 2: Serve the React app with NGINX
FROM nginx:latest

# Copy custom nginx configuration file
COPY nginx.conf /etc/nginx/nginx.conf

# Copy the build files from your React app into the NGINX root directory
COPY --from=build /app/build /usr/share/nginx/html

# Expose port 80 to allow external access
EXPOSE 80

# Command to run NGINX
CMD ["nginx", "-g", "daemon off;"]

I wanted to serve the react app using ngnix, so I first created the react app using the node image and once the react application is built, I use another image and serve the react application from this image, exposing the port 80,

Note that I had to create a nginx.conf file in my project code. This config file is then copied to the nginx config folder.

Docker Hub

Now that we have our dockerfiles that allow us to create docker images, we want an easy way to use this images on other machines. The best solution I found to make these images portable was to push them on a Docker Hub repository. Docker Hub is an online platform for storing, sharing, and managing Docker container images.

We first need to create a Docker Hub account. Once you have your account, you can easily push the images from the CLI to the cloud repository, like you do with your code and github:

1
2
3
4
> docker login
#We create a copy of the image names "mysql-image" but adding the name of the repository as prefix (in my case adriapt)
> docker tag mysql-image adriapt/mysql-image
> docker push adriapt/mysql-image

We can now go to our Docker Hub and check that our images are there.

Untitled

Just like Github, we can download these images on another hosts using the docker pull command:

Untitled

When the image is in another host, we can just start a container and we will have our service running on another machine, without the need to install other dependencies:

Untitled

Using Github Actions to Dockerize the images

Now that we have been able to Dockerize the app and upload it to Dockerhub, we would like to automate it. This way, whenever we push a new release, the new version gets Dockerized and pushed to our DockerHub without the need to run all the commands previously mentioned.

I created a new workflow file, this way if the first workflow that runs the test fails, we don’t build a new image. We define this adding this code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
on:
  workflow_run:
    workflows: ["Go Test with MySQL"]
    types:
      - completed

jobs:
  on-success:
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    steps:
      - run: echo 'The triggering workflow passed'
  on-failure:
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.conclusion == 'failure' }}
    steps:
      - run: | 
          echo 'The triggering workflow failed'
          exit 1

The “Go Test with MySQL” is the other workflow that we are referencing. Then, under the “jobs” clause, we check the status of the conclusion using github.event.workflow_run.conclusion , if it fails, we exit the workflow. Otherwise, we continue with the execution.

I used this guide to know what actions should I use:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
build_deploy: 
    runs-on: ubuntu-latest
    steps:
    - name: Checkout code
      uses: actions/checkout@v4

    - name: Set up QEMU
      uses: docker/setup-qemu-action@v3

    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v3
    - name: Login to Docker Hub
      uses: docker/login-action@v3
      with: 
        username: ${{secrets.DOCKERHUB_USERNAME}}
        password: ${{secrets.DOCKERHUB_PASSWORD}}

I used Github secrets to store my DockerHub credentials. I faced a problem where the env variables where not accessible when building the images. To solve this, I created the .env file inside the docker image, so when building the app it can retrieve the variables from the file.

Then, I use the build-push-action to build the image, define the tag and push it to the DockerHub where I previously logged on:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
 - name : Create backend .env file
      working-directory: ./backend
      run: |
          touch .env
          echo "DB_PASSWORD=${{ secrets.DB_PASSWORD }}" >> .env
          echo "DB_USERNAME=${{ secrets.DB_USERNAME }}" >> .env
          echo "DB_HOSTNAME=${{ secrets.DB_HOSTNAME }}" >> .env
          echo "DB_PORT=${{ secrets.DB_PORT }}" >> .env
          echo "DB_DATABASE=${{ secrets.DB_DATABASE}}" >> .env
          echo "JWT_KEY=${{ secrets.JWT_KEY }}" >> .env
          echo "API_URL=${{ secrets.API_URL }}" >> .env
          echo "FRONTEND_URL=${{ secrets.FRONTEND_URL }}" >> .env
          echo "API_PORT=${{ secrets.API_PORT}}" >> .env
          echo "FRONTEND_PORT=${{ secrets.FRONTEND_PORT }}" >> .env
    - name: Build and push backend
      uses: docker/build-push-action@v5
      with:
        context: ./backend
        push: true
        tags: adriapt/backend:latest  

Same with the Frontend:

1
2
3
4
5
6
7
8
9
10
11
12
 - name : Create frontend .env file
      working-directory: ./frontend
      run: |
          touch .env
          echo "REACT_APP_API_HOSTNAME=${{ secrets.REACT_APP_API_HOSTNAME }}" >> .env
          echo "REACT_APP_FRONTEND_HOSTNAME=${{ secrets.REACT_APP_FRONTEND_HOSTNAME }}" >> .env
    - name: Build and push frontend
      uses: docker/build-push-action@v5
      with:
        context: ./frontend
        push: true
        tags: adriapt/frontend:latest

The whole workflow file looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
name: Build and Deploy 

on:
  workflow_run:
    workflows: ["Go Test with MySQL"]
    types:
      - completed

jobs:
  on-success:
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    steps:
      - run: echo 'The triggering workflow passed'
  on-failure:
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.conclusion == 'failure' }}
    steps:
      - run: | 
          echo 'The triggering workflow failed'
          exit 1
  build_deploy: 
    runs-on: ubuntu-latest
    steps:
    - name: Checkout code
      uses: actions/checkout@v4

    - name: Set up QEMU
      uses: docker/setup-qemu-action@v3

    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v3
    - name: Login to Docker Hub
      uses: docker/login-action@v3
      with: 
        username: ${{secrets.DOCKERHUB_USERNAME}}
        password: ${{secrets.DOCKERHUB_PASSWORD}}
    - name : Create backend .env file
      working-directory: ./backend
      run: |
          touch .env
          echo "DB_PASSWORD=${{ secrets.DB_PASSWORD }}" >> .env
          echo "DB_USERNAME=${{ secrets.DB_USERNAME }}" >> .env
          echo "DB_HOSTNAME=${{ secrets.DB_HOSTNAME }}" >> .env
          echo "DB_PORT=${{ secrets.DB_PORT }}" >> .env
          echo "DB_DATABASE=${{ secrets.DB_DATABASE}}" >> .env
          echo "JWT_KEY=${{ secrets.JWT_KEY }}" >> .env
          echo "API_URL=${{ secrets.API_URL }}" >> .env
          echo "FRONTEND_URL=${{ secrets.FRONTEND_URL }}" >> .env
          echo "API_PORT=${{ secrets.API_PORT}}" >> .env
          echo "FRONTEND_PORT=${{ secrets.FRONTEND_PORT }}" >> .env
    - name: Build and push backend
      uses: docker/build-push-action@v5
      with:
        context: ./backend
        push: true
        tags: adriapt/backend:latest  
    - name : Create frontend .env file
      working-directory: ./frontend
      run: |
          touch .env
          echo "REACT_APP_API_HOSTNAME=${{ secrets.REACT_APP_API_HOSTNAME }}" >> .env
          echo "REACT_APP_FRONTEND_HOSTNAME=${{ secrets.REACT_APP_FRONTEND_HOSTNAME }}" >> .env
    - name: Build and push frontend
      uses: docker/build-push-action@v5
      with:
        context: ./frontend
        push: true
        tags: adriapt/frontend:latest
This post is licensed under CC BY 4.0 by the author.