DEV Community

Cover image for Building, Securing, and Deploying a Go App with GitLab CI/CD EP 6: Deploy and Scan Your Go API with OWASP ZAP in GitLab CI/CD
Booranasak Kanthong
Booranasak Kanthong

Posted on

Building, Securing, and Deploying a Go App with GitLab CI/CD EP 6: Deploy and Scan Your Go API with OWASP ZAP in GitLab CI/CD

Introduction

Welcome to Episode 6 of our CI/CD journey!
So far, you’ve built a secure pipeline: building, testing, scanning, and pushing your app image automatically. In this episode, you’ll learn how to automate the deployment of your Docker image and use OWASP ZAP to scan your running API for vulnerabilities—just like professional DevOps teams do.

  • Recap: Up to now, you’ve built, tested, scanned, and pushed your app image.
  • What’s next: Deploying your app, then scanning your running API for security issues using OWASP ZAP.
  • Why it matters: Even a secure image can expose vulnerabilities once deployed—API scanning helps catch those.

1. Automated Deployment from GitLab CI/CD

A big step in DevOps is letting your pipeline deploy the latest app image for you—no more manual server logins!
We’ll use GitLab CI/CD to connect to a remote server via SSH and run our new Docker image.

Why Use SSH Keys?

  • SSH keys keep your connection secure without exposing passwords.
  • Store your keys as GitLab CI/CD variables (like SSH_PRIVATE_KEY), never hard-code secrets.

I’ve already covered this topic, so you can follow along using the link below.
Setup SSH Key Method

Example Deployment Job

deploy_to_production:
  stage: deploy
  image: google/cloud-sdk:latest
  before_script:
    - echo "Install Docker"
    - apt-get update && apt-get install -y docker.io curl ca-certificates tar gzip openssh-client
    - echo "Setting up Google Cloud authentication..."
    - echo "$GCP_SA_KEY" | base64 -d > $CI_PROJECT_DIR/gcp-key.json
    - gcloud auth activate-service-account --key-file=$CI_PROJECT_DIR/gcp-key.json
    - gcloud config set project escian
    - gcloud auth configure-docker asia-southeast1-docker.pkg.dev --quiet
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
    - chmod 644 ~/.ssh/known_hosts
    - ssh-keyscan $REMOTE_HOST >> ~/.ssh/known_hosts # Good for adding on the fly if not using a variable
    - ls -lrta ~/.ssh
    - cat ~/.ssh/known_hosts
    - id
  script:
    - ssh $REMOTE_USER@$REMOTE_HOST "docker run -d -p 8502:8002 $IMAGE_TAG_GOOGLE && docker ps | grep uvicorn | awk '{print \$1}' > CONTAINER_ID.txt &"
Enter fullscreen mode Exit fullscreen mode
  • What this does:
  • Install Docker
  • Authenticates with Google Cloud so the image can be pulled
  • Sets up SSH, authenticates securely, and runs your Docker container on the remote host.

If you use Docker

deploy_to_production:
  stage: deploy
  image: alpine
  before_script:
    # Install openssh-client if not present in the image
    - apk add --no-cache openssh-client
    # Set up SSH agent
    - eval $(ssh-agent -s)
    # Add the private key from the CI/CD secret
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    # Create the .ssh directory and add known hosts
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
    - chmod 644 ~/.ssh/known_hosts
    - ssh-keyscan $REMOTE_HOST >> ~/.ssh/known_hosts # Good for adding on the fly if not using a variable
    - ls -lrta ~/.ssh
    - cat ~/.ssh/known_hosts
    - id
  script:
    - ssh $REMOTE_USER@$REMOTE_HOST "docker run -d -p 8502:8002 mirrorheart/todo-api-golang && docker ps | grep uvicorn | awk '{print $1}' > CONTAINER_ID.txt &"
Enter fullscreen mode Exit fullscreen mode

2. Health Checking Your API Before Scanning

You don’t want to scan your API until it’s actually running!
A health check waits for your deployed app to be up before moving forward.

Sample Health Check Script

for i in {1..10}; do
  if curl -sSf "$APP_URL_HEALTH" > /dev/null; then
    echo "Target is up."
    break
  else
    echo "Waiting for target... ($i/10)"
    sleep 5
  fi
  if [ $i -eq 10 ]; then
    echo "Target not reachable after 50 seconds."
    exit 1
  fi
done
Enter fullscreen mode Exit fullscreen mode
  • This script pings your app up to 10 times, waiting up to 50 seconds before failing if it can't connect.

3. API Security Scanning with OWASP ZAP

What is OWASP ZAP?

OWASP ZAP (Zed Attack Proxy) is a free, open-source security tool for finding vulnerabilities in web applications and APIs.
It checks your running app for issues that code or image scans can’t find—like missing authentication, insecure endpoints, or sensitive data leaks.

Why Use It in CI/CD?

  • Scans the real, running API for real-world security problems
  • Catches issues before users (or attackers) find them

Add ZAP to Your Pipeline

zap_scan:
  stage: zap_scan
  image: zaproxy/zap-stable
  script:
    - echo "Checking if the target is reachable..."
    - # (paste the health check script here)
    - mkdir -p /zap/wrk
    - ln -s "$CI_PROJECT_DIR" /zap/wrk
    - echo "Starting ZAP baseline scan..."
    - zap-baseline.py -t "$APP_URL" -r zap-report.html -I
    - cp /zap/wrk/zap-report.html "$CI_PROJECT_DIR/" || true
  artifacts:
    paths:
      - zap-report.html
    expire_in: 7 days
  allow_failure: true
Enter fullscreen mode Exit fullscreen mode
  • What this does: Waits for your API to be up, runs a baseline ZAP scan, and saves the full report as a downloadable artifact.

You will get file .ZIP (original name: artifact.zip)

I have renamed the file for better clarity.

Let's take a look at the report file

Quite long


4. Reading & Acting on ZAP Reports

  • Go to your pipeline in GitLab and click the zap_scan job.
  • Download and open zap-report.html to see results.

What to Look For

  • High or Medium risk findings: Fix these before deploying to users!
  • Auth or access control warnings: Make sure only the right people can access sensitive endpoints.
  • Informational/Low findings: Good to know, but not urgent.

Alert Detail

Risk Level: Low
Issue: Insufficient Site Isolation Against Spectre Vulnerability
Description: Your application does not set the Cross-Origin-Resource-Policy (CORP) header. This header is crucial for mitigating side-channel attacks like Spectre by controlling how resources are shared across origins.


Risk Level: Low
Issue: X-Content-Type-Options Header Missing
Description:Your application does not set the X-Content-Type-Options HTTP response header to nosniff. This header is critical for preventing MIME-sniffing by older browsers (like Internet Explorer and Chrome), which may otherwise interpret the response body as a different content type than declared — potentially allowing cross-site scripting (XSS) or other injection attacks on certain responses.


Informational
Risk Level: Informational
Issue: Storable and Cacheable Content
Description: The HTTP responses from your web server do not include explicit caching directives, which causes proxy or browser caches to apply heuristic expiration policies (e.g., 1 year by default). This can be a privacy risk, especially if:

  • The content is sensitive, personal, or user-specific.
  • Shared caches (e.g., corporate or school proxies) are involved.
  • Such responses may be retrieved by others without a direct request to the origin server, risking exposure of private data or even session hijacking.


5. (Optional) Stopping Your App

You might want to stop your demo or test container after scanning.

stop_to_production:
  stage: deploy
  image: alpine
  before_script:
    - apk add --no-cache openssh-client
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
    - chmod 644 ~/.ssh/known_hosts
    - ssh-keyscan $REMOTE_HOST >> ~/.ssh/known_hosts # Good for adding on the fly if not using a variable
    - ls -lrta ~/.ssh
    - cat ~/.ssh/known_hosts
    - id
  script:
    - ssh $REMOTE_USER@$REMOTE_HOST "cat CONTAINER_ID.txt | xargs docker stop"
  when: manual
Enter fullscreen mode Exit fullscreen mode
  • Tip: Mark this job as manual so it only runs when you trigger it.

6. Merge scripts together

It will look like this

image: docker:latest

services:
  - docker:dind

variables:
  DOCKER_HOST: tcp://docker:2375/
  DOCKER_TLS_CERTDIR: ""
  GO_VERSION: "1.24.3"
  SONAR_USER_HOME: "${CI_PROJECT_DIR}/.sonar"
  IMAGE_NAME_GOOGLE: "booranasak-bank-api-golang"
  IMAGE_VERSION_TAG: "1.0.0"
  IMAGE_TAG_GOOGLE: "asia-southeast1-docker.pkg.dev/escian/booranasak-artifact-registry-docker/$IMAGE_NAME_GOOGLE:$IMAGE_VERSION_TAG"  
  IMAGE_TAR: todo-api.tar
  GIT_DEPTH: "0"
  REMOTE_USER: booranasak42545
  REMOTE_HOST: 35.209.94.73
  REMOTE_APP_DIR: /home/booranasak42545/deploy_repo
  APP_URL: http://35.209.94.73:8502
  APP_URL_HEALTH: http://35.209.94.73:8502

stages:
  - lint
  - test
  - sast
  - build
  - push
  - sca_image
  - deploy
  - zap_scan

.go-job-template: &go-job-template
  image: debian:bullseye
  before_script:
    - apt update && apt install -y curl git tar gzip
    - curl -LO https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz
    - rm -rf /usr/local/go && tar -C /usr/local -xzf go${GO_VERSION}.linux-amd64.tar.gz
    - export PATH="/usr/local/go/bin:$PATH"
    - go version

lint_golint:
  stage: lint
  <<: *go-job-template
  script:
    - export PATH="/usr/local/go/bin:$PATH"
    - go install golang.org/x/lint/golint@latest
    - export PATH="$PATH:$(go env GOPATH)/bin"
    - echo "Linting files:"
    - find . -name '*.go'
    - golint ./... | tee lint-report.txt
    - echo "--- Lint report preview ---"
    - cat lint-report.txt || echo "lint-report.txt is empty"
  allow_failure: true
  artifacts:
    name: "golint-report"
    paths:
      - lint-report.txt
    expire_in: 1 week

unit_test_and_coverage:
  stage: test
  <<: *go-job-template
  script:
    - export PATH="/usr/local/go/bin:$PATH"
    - go mod tidy
    - go test -v -cover ./...
    - go test -v -coverprofile=coverage.out ./...
  artifacts:
    paths:
      - coverage.out
    expire_in: 1 hour

sonarqube-check:
  stage: sast
  image:
    name: sonarsource/sonar-scanner-cli:11
    entrypoint: [""]
  dependencies:
    - unit_test_and_coverage
  script: 
    - sonar-scanner -Dsonar.host.url="${SONAR_HOST_URL}" -Dsonar.go.coverage.reportPaths=coverage.out -Dsonar.exclusions=**/*_test.go
  allow_failure: true
  rules:
    - if: $CI_PIPELINE_SOURCE == 'merge_request_event'
    - if: $CI_COMMIT_BRANCH == 'master'
    - if: $CI_COMMIT_BRANCH == 'main'
    - if: $CI_COMMIT_BRANCH == 'develop'

sonarqube-vulnerability-report:
  stage: sast
  image:
    name: sonarsource/sonar-scanner-cli:11
    entrypoint: [""]
  script:
    - 'curl -u "${SONAR_TOKEN}:" "${SONAR_HOST_URL}/api/issues/gitlab_sast_export?projectKey=booranasak-golang-sast&branch=${CI_COMMIT_BRANCH}&pullRequest=${CI_MERGE_REQUEST_IID}" -o gl-sast-sonar-report.json'
  allow_failure: true
  rules:
    - if: $CI_PIPELINE_SOURCE == 'merge_request_event'
    - if: $CI_COMMIT_BRANCH == 'master'
    - if: $CI_COMMIT_BRANCH == 'main'
    - if: $CI_COMMIT_BRANCH == 'develop'
  artifacts:
    expire_in: 1 day
    reports:
      sast: gl-sast-sonar-report.json

build_image:
  stage: build
  image: docker:latest
  script:
    - docker build -t $IMAGE_TAG_GOOGLE .
    - docker save $IMAGE_TAG_GOOGLE -o $IMAGE_TAR
  artifacts:
    name: "docker-image-tar"
    paths:
      - $IMAGE_TAR
    expire_in: 1 hour

push_image_to_registry:
  stage: push
  image: google/cloud-sdk:latest
  dependencies:
    - build_image
  before_script:
    - echo "Install Docker"
    - apt-get update && apt-get install -y docker.io
    - echo "Setting up Google Cloud authentication..."
    - echo "$GCP_SA_KEY" | base64 -d > $CI_PROJECT_DIR/gcp-key.json
    - gcloud auth activate-service-account --key-file=$CI_PROJECT_DIR/gcp-key.json
    - gcloud config set project escian
    - gcloud auth configure-docker asia-southeast1-docker.pkg.dev --quiet
  script:
    - docker load -i $IMAGE_TAR
    - docker push $IMAGE_TAG_GOOGLE

image_scan:
  stage: sca_image
  image: google/cloud-sdk:latest
  variables: 
    TRIVY_VERSION: "0.54.1"
  before_script:
    - echo "Install Docker"
    - apt-get update && apt-get install -y docker.io curl ca-certificates tar gzip
    - echo "Setting up Google Cloud authentication..."
    - echo "$GCP_SA_KEY" | base64 -d > $CI_PROJECT_DIR/gcp-key.json
    - gcloud auth activate-service-account --key-file=$CI_PROJECT_DIR/gcp-key.json
    - gcloud config set project escian
    - gcloud auth configure-docker asia-southeast1-docker.pkg.dev --quiet
    - curl -fsSL https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz | tar -xz -C /usr/local/bin trivy
    - trivy --version
  script:
    - echo "Scanning Docker image for vulnerabilities..."
    - trivy image $IMAGE_TAG_GOOGLE
  allow_failure: true

deploy_to_production:
  stage: deploy
  image: google/cloud-sdk:latest
  before_script:
    - echo "Install Docker"
    - apt-get update && apt-get install -y docker.io curl ca-certificates tar gzip openssh-client
    - echo "Setting up Google Cloud authentication..."
    - echo "$GCP_SA_KEY" | base64 -d > $CI_PROJECT_DIR/gcp-key.json
    - gcloud auth activate-service-account --key-file=$CI_PROJECT_DIR/gcp-key.json
    - gcloud config set project escian
    - gcloud auth configure-docker asia-southeast1-docker.pkg.dev --quiet
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
    - chmod 644 ~/.ssh/known_hosts
    - ssh-keyscan $REMOTE_HOST >> ~/.ssh/known_hosts # Good for adding on the fly if not using a variable
    - ls -lrta ~/.ssh
    - cat ~/.ssh/known_hosts
    - id
  script:
    - ssh $REMOTE_USER@$REMOTE_HOST "docker run -d -p 8502:8002 $IMAGE_TAG_GOOGLE && docker ps | grep uvicorn | awk '{print \$1}' > CONTAINER_ID.txt &"

stop_to_production:
  stage: deploy
  image: alpine
  before_script:
    - apk add --no-cache openssh-client
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
    - chmod 644 ~/.ssh/known_hosts
    - ssh-keyscan $REMOTE_HOST >> ~/.ssh/known_hosts # Good for adding on the fly if not using a variable
    - ls -lrta ~/.ssh
    - cat ~/.ssh/known_hosts
    - id
  script:
    - ssh $REMOTE_USER@$REMOTE_HOST "cat CONTAINER_ID.txt | xargs docker stop"
  when: manual

zap_scan:
  stage: zap_scan
  image: zaproxy/zap-stable
  script:
    - echo "Checking if the target is reachable..."
    - |
      for i in {1..10}; do
        if curl -sSf "$APP_URL_HEALTH" > /dev/null; then
          echo "Target is up."
          break
        else
          echo "Waiting for target... ($i/10)"
          sleep 5
        fi
        if [ $i -eq 10 ]; then
          echo "Target not reachable after 50 seconds."
          exit 1
        fi
      done
    - mkdir -p /zap/wrk
    - ln -s "$CI_PROJECT_DIR" /zap/wrk
    - echo "Starting ZAP baseline scan..."
    - zap-baseline.py -t "$APP_URL" -r zap-report.html -I
    - cp /zap/wrk/zap-report.html "$CI_PROJECT_DIR/" || true
  artifacts:
    paths:
      - zap-report.html
    expire_in: 7 days
  allow_failure: true
Enter fullscreen mode Exit fullscreen mode


7. Wrap Up and What’s Next

Congrats—you’ve now automated deployment and API scanning for your Go app using GitLab CI/CD and OWASP ZAP!
This means every change you make gets checked for code bugs, image vulnerabilities, and live API security—all before users ever see it.

What else can you automate?

  • Slack/email notifications for new vulnerabilities
  • Deploying to cloud platforms like GCP or AWS

Thank you for following along!
Be sure to check out the earlier episodes and stay tuned for the next one.

Top comments (0)