Back to Blog
Software EngineeringTechnologyJune 18, 20266 min read

GitLab CI and Docker: Secure Microservices CI/CD

Implement a secure and scalable CI/CD pipeline for your microservices using GitLab CI and Docker. Learn best practices for containerization, automated testing, and deployment.

DevOpsLinuxServer

If you're building microservices, you know the pain. Managing deployments, ensuring consistency across environments, and keeping everything secure can quickly become a nightmare. That's where a well-defined CI/CD pipeline comes in, and for me, the combination of GitLab CI and Docker has been a game-changer.

I've spent years wrestling with various CI/CD tools, but GitLab's integrated approach, coupled with Docker's containerization magic, simplifies things immensely. In this post, I'll share how I set up a secure and scalable CI/CD pipeline for my microservices, focusing on practical steps you can implement today.

Why GitLab CI and Docker for Microservices?

Let's break down why this duo is so powerful for a microservices architecture.

GitLab CI: Integrated and Powerful

GitLab's CI/CD capabilities are built right into the platform. This means no more juggling separate tools for version control and CI/CD. Everything lives in one place, which drastically reduces complexity and overhead.

  • Single Application: Version control, CI/CD, container registry, issue tracking – it's all there. This unified experience is a massive productivity booster.
  • YAML-based Configuration: .gitlab-ci.yml is your pipeline definition file. It's version-controlled alongside your code, making changes transparent and easily reversible.
  • Scalability: GitLab Runners can be configured to scale dynamically, handling your build and deployment jobs efficiently.
  • Security Features: Built-in security scanning tools (SAST, DAST, dependency scanning) help catch vulnerabilities early.

Docker: The Microservices Standard

Docker is, in my opinion, the de facto standard for containerizing applications, especially microservices.

  • Environment Consistency: "It works on my machine" is a phrase you'll hear less often. Docker containers package your application and its dependencies, ensuring it runs the same way everywhere – dev, staging, production.
  • Isolation: Each microservice runs in its own isolated container, preventing dependency conflicts.
  • Scalability: Docker containers are lightweight and can be spun up or down rapidly, making them ideal for scaling microservices independently.
  • Portability: Docker images can run on any machine with Docker installed, from your laptop to a cloud server.

Designing the CI/CD Pipeline

My goal was to create a pipeline that automates the entire lifecycle of a microservice, from code commit to deployment, while prioritizing security and rapid feedback.

Here's a typical flow for a single microservice:

  1. Commit Code: Developer pushes code to a feature branch in GitLab.
  2. Merge Request (MR): Developer opens an MR to merge into the main branch.
  3. CI Pipeline Triggered: GitLab CI automatically detects the MR and triggers the pipeline.
    • Linting & Static Analysis: Check code quality and style.
    • Unit Tests: Run fast, isolated tests.
    • Build Docker Image: Create a Docker image for the microservice.
    • Security Scanning: Scan the Docker image for vulnerabilities.
    • Integration Tests: Test the microservice in a more integrated environment (often with other dependent services mocked or running in Docker Compose).
  4. Merge to main: If the pipeline passes, the MR can be merged.
  5. Deployment Pipeline Triggered: Pushing to main triggers a separate deployment pipeline.
    • Build & Tag Docker Image: Build the final production-ready Docker image and tag it (e.g., with the commit SHA or a semantic version).
    • Push to Registry: Push the tagged image to GitLab's Container Registry (or another registry like Docker Hub/ECR).
    • Deploy to Staging: Deploy the new image to a staging environment.
    • Smoke Tests/End-to-End Tests: Run high-level tests on the staging environment.
    • Manual Approval (Optional): A gate before production.
    • Deploy to Production: Deploy the image to the production environment.

Practical Implementation with GitLab CI and Docker

Let's get hands-on. We'll assume you have a simple Node.js microservice.

1. The Dockerfile

First, you need a Dockerfile for your microservice. This defines how to build your container image.

Dockerfile
# Use an official Node.js runtime as a parent image
FROM node:18-alpine AS builder

# Set the working directory in the container
WORKDIR /app

# Copy package.json and package-lock.json (or yarn.lock)
COPY package*.json ./

# Install dependencies
RUN npm ci --only=production

# Copy the rest of the application code
COPY . .

# Expose the port the app runs on
EXPOSE 3000

# Define the command to run your app
CMD [ "node", "server.js" ]

Explanation:

  • FROM node:18-alpine: We start with a lean Alpine Linux-based Node.js image. Alpine is great for keeping image sizes down.
  • WORKDIR /app: Sets the default directory for subsequent commands.
  • COPY package*.json ./ and RUN npm ci --only=production: Copies dependency files and installs only production dependencies. npm ci is generally faster and more reliable for CI environments than npm install.
  • COPY . .: Copies your application code into the image.
  • EXPOSE 3000: Informs Docker that the container listens on port 3000 at runtime.
  • CMD [ "node", "server.js" ]: Specifies the command to execute when the container starts.

2. The .gitlab-ci.yml Pipeline

Now, let's define our CI/CD pipeline in .gitlab-ci.yml. This file will live at the root of your microservice's repository.

YAML
# Use the official Docker image for Docker commands
image: docker:20.10.16

# Define services needed for the pipeline (e.g., Docker-in-Docker)
services:
  - docker:20.10.16-dind

variables:
  # Use the project's registry for images
  IMAGE_TAG_LATEST: $CI_REGISTRY_IMAGE:latest
  IMAGE_TAG_COMMIT: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
  # Cache npm dependencies to speed up builds
  npm_config_cache: "$CI_PROJECT_DIR/.npm"

# Cache configuration
cache:
  key: ${CI_COMMIT_REF_SLUG} # Cache per branch
  paths:
    - .npm/

stages:
  - build
  - test
  - scan
  - deploy

# Job 1: Linting and Static Analysis
lint:
  stage: build
  image: node:18-alpine
  script:
    - echo "Running linters..."
    - npm install # Install dev dependencies for linting
    - npm run lint # Assuming you have a 'lint' script in package.json
  artifacts:
    when: always
    reports:
      junit: report.xml # If your linter can output JUnit format

# Job 2: Unit Tests
unit_tests:
  stage: test
  image: node:18-alpine
  script:
    - echo "Running unit tests..."
    - npm install # Install dev dependencies for testing
    - npm run test # Assuming you have a 'test' script in package.json
  artifacts:
    when: always
    reports:
      junit: report.xml # If your test runner can output JUnit format

# Job 3: Build Docker Image
build_image:
  stage: build
  script:
    - echo "Building Docker image..."
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
    - docker build -t $IMAGE_TAG_LATEST -t $IMAGE_TAG_COMMIT .
    - docker push $IMAGE_TAG_LATEST
    - docker push $IMAGE_TAG_COMMIT
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"' # Only run on main branch

# Job 4: Security Scanning (Example: Trivy)
# You'll need to install Trivy on your runner or use a Trivy image
security_scan:
  stage: scan
  image: aquasec/trivy:latest
  script:
    - echo "Scanning Docker image for vulnerabilities..."
    - trivy image --severity HIGH,CRITICAL --exit-code 1 $IMAGE_TAG_COMMIT
  allow_failure: false # Fail the pipeline if critical vulnerabilities are found
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

# Job 5: Deploy to Staging
deploy_staging:
  stage: deploy
  image: alpine:latest # Or an image with kubectl/helm/ssh clients
  script:
    - echo "Deploying to staging environment..."
    # Add your deployment script here (e.g., kubectl apply, helm upgrade, ssh deploy)
    # Example: Deploying a Kubernetes manifest
    # - apk add --no-cache openssh-client git
    # - ssh user@staging-server "kubectl apply -f k8s/staging-

Similar Posts