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 &"
- 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 &"
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
- 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
- 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
-
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
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)