DEV Community

Binamra Pandey
Binamra Pandey

Posted on AI-assisted

What I Learned Setting Up My First CI/CD Pipeline (Docker + GitHub Actions)

This was a simplest pipeline which I could think of for practicing Docker and Github Action pipeline.

Why I Built This

I have been learning the working of devops recently. So, I wanted to build something to practice what I have learned so far. So I thought of building this simple workflow.

This is the basic flow of the pipeline.
P.S: Don't mind my diagram, I am learning to draw those too.

The Project

The app itself is a tiny Flask API with two routes, / and /health. That's it. I kept it deliberately simple because the app was never the point of this project. The pipeline was.
If I'd built something complex, I'd have spent most of my time debugging application logic instead of learning how GitHub Actions actually works. A "Hello World" endpoint gives you something real to test, build, and deploy, without getting in the way.

flask-cicd-demo/
├── .github/
│   └── workflows/
│       └── ci-cd.yml
├── src/
│   ├── app.py
│   └── requirements.txt
├── tests/
│   └── test_app.py
├── dockerfiles/
│   └── python.dockerfile
├── docker-compose.yml
├── .dockerignore
└── .gitignore
Enter fullscreen mode Exit fullscreen mode

P.S: I used docker-compose here even though it wasn't multi-container because I just wanted to practice that too.

Stage 1: Running tests in CI

The idea here was simple: one job, triggered on push, that checks out the code, installs dependencies, and runs pytest. Nothing else yet. I wanted this piece working before touching Docker at all.

jobs:
  test_job:
    runs-on: ubuntu-latest
    steps:
      - name: checkout code
        uses: actions/checkout@v4
      - name: setup python
        uses: actions/setup-python@v4
        with:
          python-version: 3.11
      - name: install dependencies
        run: |
          cd src && pip install -r requirements.txt
          cd ..
      - name: run tests
        run: |
          PYTHONPATH=src pytest tests/
Enter fullscreen mode Exit fullscreen mode

When I ran the first time, I got straight error.


This was my simple mistake as I didn't add pytest module in the requirments.txt file. Adding it, fixed the issue.

Now, while running it for the second time, I hit the wall again. My workflow return with error, No module name app.


My test_app.py imports the app like this:

from app import app
Enter fullscreen mode Exit fullscreen mode

Which is fine locally if you're running things from inside src/. But app.py actually lives in src/, not the repo root, and pytest runs from the root by default. So Python had no idea where to look. The fix was adding PYTHONPATH=src before the pytest command, which just tells Python "also check inside src/ when you're resolving imports."

Now after this, I finally got my test_job flow to work.

Stage 2: Building the Docker Image in CI

With tests running, the next piece was getting the pipeline to actually build the Docker image. My Dockerfile lives at dockerfiles/python.dockerfile, not at the project root, and it copies files in like this:

COPY src/requirements.txt /app
COPY src/ /app
Enter fullscreen mode Exit fullscreen mode

Since I was building through docker-compose.yml, I had to get the build context right. My first attempt looked like this:

build:
  context: ./dockerfiles
  dockerfile: python.dockerfile
Enter fullscreen mode Exit fullscreen mode

That failed. The build context only included the dockerfiles/ folder, so src/ was invisible to it, and COPY src/requirements.txt /app had nothing to find.

The fix was keeping the context at the project root, and pointing dockerfile: at where the file actually sits, relative to that root:

build:
  context: .
  dockerfile: dockerfiles/python.dockerfile
Enter fullscreen mode Exit fullscreen mode

Context and Dockerfile path are two separate things, and it's easy to mix them up if your Dockerfile isn't sitting in the default spot. So after this I finally got the docker working too.

Stage 3: Job Artifacts

This was the part I actually wanted to practice going into this project.

Artifacts are basically files a job produces that you want to keep after the job finishes. Once a runner's done, its filesystem is gone, so if you don't save something explicitly, it's just gone with it. actions/upload-artifact handles that, you give it a name and a path, and it gets attached to the run, downloadable from the Actions summary, or pulled by another job later with actions/download-artifact.

First artifact: a coverage report.

I added pytest-cov to requirements.txt, generated a coverage file when tests ran, and uploaded it so I could grab it from the Actions run summary:

- name: run tests
  run: |
    PYTHONPATH=src pytest tests/ --cov=src --cov-report=xml
- name: Upload Job Artifacts
  uses: actions/upload-artifact@v4
  with:
    name: test-results
    path: coverage.xml
Enter fullscreen mode Exit fullscreen mode

First run threw a error.


I'd added the flag before actually installing the plugin. Pytest doesn't know --cov exists unless pytest-cov is there. Added it to requirements.txt, ran again, fixed.

Second artifact: the built Docker image itself.

This one's more interesting, it's not just a report to look at, it's something a later job actually uses instead of rebuilding from scratch:

- name: save docker image
  run: |
    docker save -o flaskapp.tar flaskapp:latest
- name: Upload Job Artifacts
  uses: actions/upload-artifact@v4
  with:
    name: docker-image
    path: flaskapp.tar
Enter fullscreen mode Exit fullscreen mode

This one broke immediately:


Turns out docker compose build doesn't tag the image as flaskapp:latest by default. Compose names it -:latest unless you tell it otherwise. I had no idea that was the default until I hit this.
Fix was adding an explicit image: key to docker-compose.yml:

services:
  flaskapp:
    image: flaskapp:latest
    build:
      context: .
      dockerfile: dockerfiles/python.dockerfile
Enter fullscreen mode Exit fullscreen mode

After that, I was able to save the docker image file too.

Stage 4: Deploying via a Self-Hosted Runner

For the last piece, I wanted the pipeline to actually deploy somewhere real instead of just building and stopping. I set up a self-hosted runner on my own machine for this.

deploy_job:
  needs: build_job
  runs-on: self-hosted
  steps:
    - name: Download Job Artifacts
      uses: actions/download-artifact@v4
      with:
        name: docker-image
        path: .
    - name: load docker image
      run: |
        docker load -i flaskapp.tar
    - name: run docker container
      run: |
        docker stop flaskapp || true
        docker rm flaskapp || true
        docker run -d -p 5000:5000 --name flaskapp flaskapp
Enter fullscreen mode Exit fullscreen mode

Hit two separate issues getting here.

The first one was with the download step. I had path: flaskapp.tar under it, since that's the file I actually wanted back. Made sense to me at the time. But path: for download-artifact isn't a filename, it's a directory you're downloading into. So instead of getting my .tar file back, it tried creating a folder called flaskapp.tar. Next step, docker load -i flaskapp.tar, obviously had no idea what to do with a folder. Fixed it by setting path: ., which just downloads the artifact into the current working directory, where the file actually shows up as flaskapp.tar like I expected in the first place.

Second issue showed up only on the second deploy, not the first, which threw me off for a bit. docker run -d -p 5000:5000 --name flaskapp flaskapp worked fine the first time. Ran the pipeline again, and it failed, complaining that a container named flaskapp already existed. Which, fair, it did, the one from the last deploy was still sitting there running. I hadn't thought about that at all while writing the step. Fix was stopping and removing the old container before starting the new one.

Wrapping Up

That's the full pipeline, test, build, two kinds of artifacts, deploy, all wired together and actually working end to end on my own machine.

I learned a lot from this project. I learned things like how github actions works, different actions like upload and download artifacts. Moreover, I had no idea I can save docker image file in tar. I still have many stuff to learn and will be updating/tinkering with this small project.

Thank you for reading.

P.S: If you have any ideas on how I can improve this pipeline in a way that helps me learn more, please let me know.

Project Github: https://github.com/binamra-linux/simple-workflow

Top comments (1)

Collapse
 
varun_gtm profile image
Varun Gautam

great work