FedRAMP DevSecOps Pipeline on GitLab

Build a GitLab CI/CD pipeline with four security scanners and Kubernetes.

Introduction

30 Second Summary

Every time you download an app or enter your credit card online, you trust that someone checked the code for security holes before it went live. In the world of government software, that trust is formalized through compliance frameworks that demand proof of security at every stage.

In this project, you will build a DevSecOps pipeline on GitLab that automatically scans a Python web application for vulnerabilities, hardens its container image, deploys it to a local Kubernetes cluster, and maps each pipeline stage to NIST 800-53 compliance controls.

What You'll Build

When you push code, your pipeline lights up with four security scanners running automatically, reports real vulnerabilities back to you, and deploys a hardened container to your own Kubernetes cluster that you can show a recruiter as proof of hands-on DevSecOps skill.

By the end of this project, you'll have:

  • A GitLab CI/CD pipeline running four security scanners (SAST, Secret Detection, Dependency Scanning, and Container Scanning) on every push, producing real vulnerability reports you can download and review.
  • A security-hardened Docker container deployed to a local Kubernetes cluster with non-root execution, a pinned base image, and a read-only filesystem.
  • A FedRAMP control mapping document that ties each pipeline stage to specific NIST 800-53 controls, showing a recruiter you understand why each security practice exists.
  • Secret Mission: Troubleshoot a pipeline failure caused by an intentionally leaked API key, diagnosing and remediating it like a GitLab support engineer.

Are there any prerequisites?

You need Docker Desktop installed on your Mac, a free GitLab.com account, and a GitHub account for push mirroring. No prior CI/CD or Kubernetes experience is required.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters for landing a GitLab support engineer role.

Set Up Your Environment and GitLab Project

Your DevSecOps pipeline needs two things before you can build anything: a place to execute CI/CD jobs (GitLab.com) and a place to deploy your secured container (a local Kubernetes cluster).

In this step, you'll install minikube to run Kubernetes locally, create your GitLab project, and configure automatic mirroring to GitHub so recruiters can find your work on both platforms.

In this step, get ready to:
  • Install minikube and kubectl, then start your local Kubernetes cluster.
  • Create and clone a public GitLab project for your pipeline.
  • Configure push mirroring to keep your GitHub repository in sync.
Install minikube and kubectl

Minikube creates a single-node Kubernetes cluster on your Mac using Docker as the driver. The kubectl command-line tool lets you interact with that cluster.

  • Check if you already have minikube installed by running this command:
minikube version

✔️ I see a version number

Minikube is already installed. Now check kubectl by running:

kubectl version --client

If you see client version output, both tools are ready. Skip ahead to starting your cluster below.

ⓧ Command not found

  • Install both minikube and kubectl via Homebrew by running this command:
brew install minikube kubectl

What does this command do?

Homebrew downloads and installs both the minikube binary (which manages your local cluster) and kubectl (which sends commands to any Kubernetes cluster). Both are placed in your PATH automatically.

  • Confirm the install succeeded by running:
minikube version

You should see a version number in the output.

Seeing 'brew: command not found'?

Homebrew is not installed on your Mac. Visit brew.sh and follow the one-line install command, then retry the install above.

Help me troubleshoot this

Now start your local Kubernetes cluster. This command downloads a Kubernetes image and boots a single-node cluster inside Docker. It takes 1-2 minutes on the first run.

  • Start your minikube cluster by running this command:
minikube start --driver=docker --memory=4096

What does this command do?

The --driver=docker flag tells minikube to run the cluster node as a Docker container instead of a full virtual machine. The --memory=4096 flag allocates 4 GB of RAM so Kubernetes has enough room for your pipeline workloads.

Before you run the next command, what do you expect to see if your cluster started correctly?

  • Verify your cluster is healthy by running:
minikube status

You should see output showing host: Running, kubelet: Running, and apiserver: Running. This confirms all Kubernetes components are up.

  • Also confirm kubectl can talk to the cluster by running:
kubectl get nodes

You should see one node named minikube with status Ready.

Seeing 'Exiting due to DRV_NOT_HEALTHY'?

This means Docker Desktop is not running. Open Docker Desktop from your Applications folder, wait for the whale icon in the menu bar to stop animating, then retry minikube start.

Help me troubleshoot my minikube start error

Create a new project on GitLab.com

Your pipeline needs a home on GitLab where code lives and CI/CD jobs execute. You'll create a public project so the recruiter can view your green pipelines without needing a login.

  • Open your browser and sign in to your GitLab.com account.
  • Click the + icon (or New project) in the top navigation bar.
  • Select Create blank project.
  • In the Project name field, enter devsecops-fedramp-pipeline.
  • Set Visibility Level to Public.
  • Check the Initialize repository with a README box so the repo is immediately cloneable.
  • Click Create project.

GitLab redirects you to your new project page. You should see the project name devsecops-fedramp-pipeline at the top and a README file in the repository.

Now clone the project to your local machine so you can add files.

  • Open your terminal.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Clone your GitLab project by running this command:
git clone [[GITLAB_PROJECT_URL="https://gitlab.com/your-username/devsecops-fedramp-pipeline.git"]]

Where do I find my clone URL?

On your GitLab project page, click the blue Code button near the top right. Copy the HTTPS URL from the dropdown.

  • Move into the cloned project folder by running:
cd devsecops-fedramp-pipeline
  • Confirm the clone worked by running:
ls

You should see a README.md file listed.

Clone failing with 'repository not found'?

Double-check that you selected Public visibility when creating the project. Also make sure you copied the full HTTPS URL from the Code button on your project page.

Help me troubleshoot my git clone error

Set up push mirror to GitHub

Push mirroring automatically copies every push you make on GitLab to a GitHub repository. This keeps your GitHub portfolio up to date without any extra effort.

  • Go to github.com and create a new empty repository named devsecops-fedramp-pipeline.
  • Do NOT initialize it with a README, .gitignore, or license. It must be completely empty.

Next, you need a GitHub personal access token so GitLab can authenticate when pushing to GitHub.

  • On GitHub, click your profile icon in the top right, then select Settings.
  • In the left sidebar, scroll down and click Developer settings.
  • Click Fine-grained tokens, then Generate new token.
  • Give the token a name like gitlab-mirror.
  • Under Repository access, select Only select repositories and choose your devsecops-fedramp-pipeline repo.
  • Under Permissions, expand Repository permissions and set Contents to Read and write.
  • Click Generate token and copy the token. Store it somewhere safe as you will only see it once.

Now configure the push mirror in GitLab.

  • Return to your GitLab project in your browser.
  • In the left sidebar, click Settings, then click Repository.
  • Expand the Mirroring repositories section.
  • In the Git repository URL field, enter your GitHub mirror URL:
https://github.com/[[GITHUB_USERNAME="your-github-username"]]/devsecops-fedramp-pipeline.git
  • Set Mirror direction to Push.
  • In the Username field, enter your GitHub username.
  • In the Password field, paste the GitHub personal access token you just created.
  • Click Mirror repository.

GitLab shows your mirror entry in the list below with a green checkmark or a sync-pending icon. This confirms the mirror is configured.

How does push mirroring work?

Every time you push commits to your GitLab repo, GitLab automatically forwards those same commits to GitHub within a few minutes. You never need to push to both manually.

Seeing a red error icon on the mirror?

Check that your GitHub repository is completely empty (no README). Also verify that your personal access token has Contents: Read and write permission on the correct repository.

Help me troubleshoot my GitLab push mirror error

Your environment is locked and loaded. You have a running Kubernetes cluster, a public GitLab project with a push mirror to GitHub, and a local clone ready for code. Next up, you'll build the Flask app and get your first pipeline running.

Build Your App and First Pipeline

Your environment is ready. You have a running Kubernetes cluster, a GitLab project, and a GitHub mirror. Now you need something to actually deploy.

Before adding security scanning, you need a working app and a basic pipeline that builds a container image. This step gives you the early visible win of a green pipeline on GitLab. It also creates the artifact (a container image) that security scanners will analyze later.

In this step, get ready to:
  • Create a Flask web application with a health check endpoint.
  • Write a Dockerfile to containerize the app.
  • Configure a GitLab CI/CD pipeline that builds and pushes your container image.
Create the Flask application

You need a small web application that the pipeline can package into a container. A Flask app with a health check endpoint is the simplest starting point.

  • Make sure your terminal is inside the devsecops-fedramp-pipeline folder you cloned in Step 1. If not, navigate there by running:
cd ~/Desktop/devsecops-fedramp-pipeline
  • Create a new file called app.py and add the following code:
from flask import Flask, jsonify
import os

# Create the Flask application instance
app = Flask(__name__)


# Health check endpoint for container orchestration
@app.route("/health")
def health():
    return jsonify({"status": "healthy", "service": "devsecops-demo"})


# Main endpoint returning app metadata
@app.route("/")
def index():
    return jsonify({
        "app": "FedRAMP DevSecOps Demo",
        "version": "1.0.0",
        "environment": os.environ.get("APP_ENV", "development")
    })


# Run directly for local development
if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)

What does this code do?

This is a minimal Python web app using the Flask framework. It has two routes.

  • /health returns a JSON status response. Kubernetes and load balancers use this to check if your app is alive.
  • / returns app metadata including the environment name, which is read from an environment variable.
  • Save app.py.
  • Confirm the file was created by running:
ls app.py

You should see app.py printed in the output.

Don't see app.py?

  • Make sure you saved the file inside the devsecops-fedramp-pipeline folder, not in a parent directory.
  • Run pwd to check your current directory. It should end with devsecops-fedramp-pipeline.

Why isn't my file showing up when I run ls?

Containerize the application with Docker

Docker needs two files to build your app into a container: a list of Python dependencies and a Dockerfile with build instructions.

  • Create a file called requirements.txt with the project dependencies:
Flask==3.1.1
gunicorn==23.0.0
  • Create a file called Dockerfile (no extension) with the container build instructions:
# Use Python 3.12 slim as base image
FROM python:3.12-slim

WORKDIR /app

# Install Python dependencies first (better layer caching)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy the application code
COPY app.py .

EXPOSE 8080

# Run with gunicorn for production-grade serving
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "app:app"]

What does this Dockerfile do?

  • FROM python:3.12-slim starts from a lightweight Python image. This is intentionally NOT hardened yet. You will secure it in a later step.
  • COPY requirements.txt and RUN pip install installs Flask and gunicorn inside the container.
  • CMD tells the container to run your app with gunicorn (a production web server) on port 8080.
  • Save both requirements.txt and Dockerfile.
  • Build the container image locally to verify everything works by running:
docker build -t devsecops-app .

You should see Docker download the base image layers and install your dependencies. The final line shows Successfully tagged devsecops-app:latest (or a similar success message with the image ID).

  • Run the container to test your app by running:
docker run --rm -d --name devsecops-test -p 8080:8080 devsecops-app
  • Hit the health endpoint to confirm the app responds by running:
curl http://localhost:8080/health

You should see JSON output: {"service":"devsecops-demo","status":"healthy"}.

  • Stop the test container by running:
docker stop devsecops-test

Seeing 'connection refused' from curl?

  • Make sure Docker Desktop is running. Check for the Docker whale icon in your menu bar.
  • If the docker build failed, check for typos in requirements.txt or Dockerfile.
  • If port 8080 is already in use, stop whatever is using it or try -p 9090:8080 and curl port 9090 instead.

Help me debug why my Docker container won't respond to curl.

✔️ Awesome, I've got everything!

Great. You have a working containerized app. Make sure you saved all three files.

ⓧ I'd like to double check the full code

Here is app.py:

from flask import Flask, jsonify
import os

app = Flask(__name__)


@app.route("/health")
def health():
    return jsonify({"status": "healthy", "service": "devsecops-demo"})


@app.route("/")
def index():
    return jsonify({
        "app": "FedRAMP DevSecOps Demo",
        "version": "1.0.0",
        "environment": os.environ.get("APP_ENV", "development")
    })


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)

Here is requirements.txt:

Flask==3.1.1
gunicorn==23.0.0

Here is Dockerfile:

FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app.py .

EXPOSE 8080

CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "app:app"]
Create the GitLab CI/CD pipeline configuration

GitLab reads a file called .gitlab-ci.yml in your repo root to know how to build your project. This file defines stages, jobs, and what commands to run.

  • Create a file called .gitlab-ci.yml in the root of your devsecops-fedramp-pipeline folder with the following content:
# Define the pipeline stages
stages:
  - build
  - test
  - deploy

variables:
  # Required for Docker-in-Docker TLS communication
  DOCKER_TLS_CERTDIR: "/certs"

# Build job: creates and pushes the container image
build:
  stage: build
  image: docker:latest
  services:
    - docker:dind
  before_script:
    # Authenticate with GitLab's built-in container registry
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    # Build with commit-specific tag for traceability
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
    # Also tag as latest for convenience
    - docker build -t $CI_REGISTRY_IMAGE:latest .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
    - docker push $CI_REGISTRY_IMAGE:latest

What does this pipeline do?

  • stages defines the order your pipeline runs: build first, then test, then deploy. Only the build stage has a job right now.
  • docker:dind (Docker-in-Docker) lets the pipeline build Docker images inside the CI runner.
  • $CI_REGISTRY_IMAGE is a predefined GitLab variable that points to your project's container registry. You do not need to set this yourself.
  • $CI_COMMIT_SHORT_SHA tags each image with the commit hash so you can trace exactly which code produced which image.
  • Save .gitlab-ci.yml.

✔️ Awesome, I've got everything!

Great. Your pipeline config is ready. The next substep pushes everything to GitLab.

ⓧ I'd like to double check the full code

Here is the full .gitlab-ci.yml:

stages:
  - build
  - test
  - deploy

variables:
  DOCKER_TLS_CERTDIR: "/certs"

build:
  stage: build
  image: docker:latest
  services:
    - docker:dind
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
    - docker build -t $CI_REGISTRY_IMAGE:latest .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
    - docker push $CI_REGISTRY_IMAGE:latest
Push to GitLab and watch the pipeline

Everything is ready locally. Pushing to GitLab triggers the pipeline automatically because GitLab detects .gitlab-ci.yml in the repo.

  • Stage, commit, and push all your files by running:
git add . && git commit -m "Initial app with basic pipeline" && git push

Before you check the pipeline, what do you expect to see? Think about whether your build job has the base image, the Dockerfile, and registry credentials it needs to succeed.

  • Open your GitLab project in a browser.
  • In the left sidebar, select Build, then Pipelines.
  • You should see a pipeline running (or already complete). Click it to open the pipeline details.

The build job should show a green checkmark with status Passed. This confirms your Dockerfile built successfully and the image was pushed to the registry.

  • Verify the image exists: in the left sidebar, select Deploy, then Container Registry.

You should see your container image listed with a latest tag and a short SHA tag from your commit.

Pipeline failed or stuck?

  • If the build job shows failed, click on the job to read the logs. Common causes: a typo in .gitlab-ci.yml (YAML is indentation-sensitive), or a missing file that the Dockerfile tries to COPY.
  • If the pipeline is stuck as pending, GitLab shared runners may be under high load. Wait a few minutes and refresh.
  • If you see a Docker login error, make sure your project visibility is set to Public and the Container Registry is enabled under Settings > General > Visibility, project features, permissions.

Help me debug my failed GitLab CI/CD pipeline build job.

Your first pipeline is green. You now have a containerized app being automatically built and stored in GitLab's registry every time you push code. Next up, you will add security scanners to find vulnerabilities in your code, dependencies, and container image.

Add Security Scanning

Your pipeline builds and pushes a container image, but you have no visibility into whether that code or image contains vulnerabilities.

This is the gap that DevSecOps fills. In this step, you'll add GitLab's built-in security scanners to your pipeline so vulnerabilities are caught automatically on every push. You'll also seed some intentional weaknesses so the scanners have something real to find.

In this step, get ready to:
  • Add four security scanning templates to your GitLab CI/CD pipeline.
  • Introduce intentional vulnerabilities for the scanners to detect.
  • Push your changes and review the security findings in GitLab.
Add security scanner templates to your pipeline

GitLab provides pre-built CI/CD templates for common security scanners. By including these templates in your .gitlab-ci.yml, the scanners run automatically in the test stage every time you push code.

  • Open .gitlab-ci.yml in your editor.
  • Find the variables: section. Add a new CS_IMAGE variable and an include: block between variables: and build: so the top portion of the file looks like this:
stages:
  - build
  - test
  - deploy

variables:
  DOCKER_TLS_CERTDIR: "/certs"
  CS_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA

include:
  - template: Jobs/SAST.gitlab-ci.yml
  - template: Jobs/Secret-Detection.gitlab-ci.yml
  - template: Jobs/Dependency-Scanning.v2.gitlab-ci.yml
  - template: Jobs/Container-Scanning.gitlab-ci.yml

What do these templates do?

  • SAST (Static Application Security Testing) scans your source code for insecure patterns like hardcoded credentials or injection risks.
  • Secret Detection looks for accidentally committed secrets such as API keys and passwords.
  • Dependency Scanning checks your requirements.txt packages against known vulnerability databases.
  • Container Scanning examines the built Docker image for OS-level and library CVEs.

The CS_IMAGE variable tells the container scanner which image to analyze. It points to the image your build job pushes to the GitLab Container Registry.

  • Leave the existing build: job exactly as it is. The scanner templates add their own jobs to the test stage automatically.
  • Save .gitlab-ci.yml.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

stages:
  - build
  - test
  - deploy

variables:
  DOCKER_TLS_CERTDIR: "/certs"
  CS_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA

include:
  - template: Jobs/SAST.gitlab-ci.yml
  - template: Jobs/Secret-Detection.gitlab-ci.yml
  - template: Jobs/Dependency-Scanning.v2.gitlab-ci.yml
  - template: Jobs/Container-Scanning.gitlab-ci.yml

build:
  stage: build
  image: docker:latest
  services:
    - docker:dind
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
    - docker build -t $CI_REGISTRY_IMAGE:latest .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
    - docker push $CI_REGISTRY_IMAGE:latest
Introduce vulnerabilities for the scanners to find

To see the scanners in action with real findings, you'll plant two deliberate weaknesses. One is an outdated dependency with known CVEs. The other is a hardcoded database connection string that SAST will flag.

  • Open requirements.txt and add a known-vulnerable Jinja2 version at the bottom:
Flask==3.1.1
gunicorn==23.0.0
Jinja2==2.11.3

Why Jinja2 2.11.3?

Jinja2 2.11.3 has multiple known CVEs, including sandbox breakout vulnerabilities (CVE-2024-56326, CVE-2025-27516) and an XSS issue (CVE-2024-34064). The dependency scanner will flag these automatically.

  • Save requirements.txt.
  • Open app.py and find this line:
app = Flask(__name__)
  • Add a hardcoded database connection string directly below it, so the file reads:
app = Flask(__name__)

DB_URL = "postgresql://admin:password123@db.internal:5432/appdb"

What will SAST flag here?

The DB_URL variable contains a username, password, host, and port in plain text. SAST detects this as a hardcoded credential pattern. In production, you would use environment variables instead.

  • Save app.py.
Push and review security findings

All three files are ready. Time to push everything to GitLab and see the scanners run against your code and container image.

  • Stage, commit, and push your changes by running these commands:
git add . && git commit -m "Add security scanning templates and test vulnerabilities" && git push

Before you check the pipeline results, think about this: do you expect the pipeline to pass or fail? What do you think the scanners will find?

  • In GitLab, navigate to Build in the left sidebar.
  • Select Pipelines.
  • Click the latest pipeline. You'll see new jobs in the pipeline graph: semgrep-sast, secret-detection, gemnasium-dependency_scanning, and container_scanning.
  • Wait for all jobs to complete. This may take a few minutes as each scanner downloads its own analyzer image.

What should I see?

The pipeline should complete with the security scan jobs showing green (passed). The scanners report findings as artifacts but do not fail the pipeline by default. The findings are informational until you choose to enforce blocking.

  • Select the Security tab on the pipeline page.
  • Review the findings listed. You should see SAST findings related to the hardcoded connection string, dependency vulnerabilities from the outdated Jinja2 version, and container image CVEs from the unpatched base image.

Findings are intentional

Seeing vulnerability findings here is exactly what you want. You deliberately introduced these issues so the scanners have something to report. In the next step, you'll fix them and harden your container.

  • To view the raw scan data, click the semgrep-sast job in the pipeline graph.
  • Select the Artifacts section in the job page and download gl-sast-report.json. This machine-readable JSON file is what automated compliance tools consume.

Security scan jobs not appearing?

  • Make sure your stages: list includes test. The scanner templates add their jobs to the test stage by default.
  • Check that include: is at the top level of your YAML (not nested inside a job).
  • Verify the build job completed successfully. Container scanning needs the image to exist in the registry before it can scan it.

Still stuck? Why aren't my GitLab security scan jobs appearing in the pipeline?

Your pipeline now automatically scans for vulnerabilities on every push. The findings confirm real issues in your code and container. Next up, you'll fix these vulnerabilities and harden your Dockerfile to meet FedRAMP container security requirements.

Fix Findings and Harden Your Container

Your security scanners did their job. The pipeline flagged real vulnerabilities in your code, dependencies, and container image.

Those findings are the gap between "it builds" and "it is secure." In this step, you fix the vulnerabilities and harden your container image to meet FedRAMP container security requirements: pinned base image digest, non-root user, minimal packages, and no embedded secrets.

In this step, get ready to:
  • Fix the dependency and code vulnerabilities the scanners found.
  • Harden your Dockerfile for FedRAMP alignment.
  • Push and confirm the security scan findings drop to near-zero.
Fix the dependency and code vulnerabilities

The SAST scanner flagged the hardcoded database connection string in app.py. The dependency scanner flagged the vulnerable Jinja2 version in requirements.txt. Both need to go.

  • In requirements.txt, find this line:
Jinja2==2.11.3
  • Delete this entire line. Your requirements.txt should now look like this:
Flask==3.1.1
gunicorn==23.0.0

Why remove Jinja2 entirely?

Flask automatically installs the correct version of Jinja2 as its own dependency. By removing the explicit pin to a vulnerable version, pip pulls the safe version that Flask requires.

  • In app.py, find this line:
DB_URL = "postgresql://admin:password123@db.internal:5432/appdb"
  • Delete this entire line. The app does not use this variable anywhere, and a hardcoded credential in source code is exactly what SAST scanners are designed to catch.
  • Save both requirements.txt and app.py.
  • Verify the app still builds locally by running this command:
docker build -t test-app .

You should see Successfully built (or Successfully tagged test-app:latest) at the end of the output.

Build failing?

Make sure you are running docker build from inside the devsecops-fedramp-pipeline directory where your Dockerfile lives.

If you see an error about requirements.txt, check that you only deleted the Jinja2==2.11.3 line and did not accidentally remove other lines.

help me debug my Docker build failure

Harden the Dockerfile for FedRAMP alignment

Your container builds, but it still runs as root with an unpinned base image. FedRAMP container security requirements call for a pinned base image, non-root execution, vulnerability scanning before deployment, and minimal installed packages.

Why pin a base image to its digest?

A tag like python:3.12-slim can point to a different image tomorrow if the publisher pushes an update. A digest (the sha256:... hash) is immutable. Pinning to it guarantees your builds use the exact same image every time, which is a core requirement for FedRAMP configuration management (CM-2).

  • Pull the current python:3.12-slim image and note its digest by running:
docker pull python:3.12-slim

Look for the Digest: sha256:... line in the output. Copy the full sha256: value (it will be a 64-character hex string after sha256:).

  • Replace the entire contents of your Dockerfile with the following hardened version:
FROM python:3.12-slim@sha256:YOUR_PINNED_DIGEST AS base

LABEL maintainer="your-email@example.com"
LABEL org.opencontainers.image.source="https://gitlab.com/YOUR_USERNAME/devsecops-fedramp-pipeline"
LABEL org.opencontainers.image.description="FedRAMP-aligned DevSecOps demo application"

RUN adduser --disabled-password --gecos '' appuser

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir --no-compile -r requirements.txt

COPY app.py .

USER appuser

EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')" || exit 1

CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "app:app"]
  • Replace YOUR_PINNED_DIGEST with the actual digest you copied from the docker pull output.
  • Replace your-email@example.com with your actual email address.
  • Replace YOUR_USERNAME in the source label with your GitLab username.

What does this Dockerfile do?

  • FROM...@sha256: pins the base image to an exact, immutable version (NIST CM-2).
  • LABEL lines add OCI metadata for provenance tracking, so anyone inspecting the image knows where it came from.
  • RUN adduser creates a non-root user. USER appuser switches to it, enforcing least privilege (NIST AC-6).
  • --no-cache-dir --no-compile reduces the image size by skipping pip's cache and bytecode compilation.
  • HEALTHCHECK lets orchestrators (like Kubernetes) know whether the container is alive and responsive.
  • Save Dockerfile and confirm it builds by running:
docker build -t test-app .

You should see the build complete successfully. The output now includes lines for creating the appuser and setting the HEALTHCHECK.

Seeing a digest error?

If you see an error about the digest not matching, make sure you copied the full sha256: string including the prefix. It should look like sha256: followed by exactly 64 hexadecimal characters.

help me fix my Dockerfile digest pinning error

✔️ Awesome, I've got everything!

Great. Make sure you have saved all three files: requirements.txt, app.py, and Dockerfile.

ⓧ I'd like to double check the full code

Here is requirements.txt:

Flask==3.1.1
gunicorn==23.0.0

Here is app.py:

from flask import Flask, jsonify
import os

app = Flask(__name__)


@app.route("/health")
def health():
    return jsonify({"status": "healthy", "service": "devsecops-demo"})


@app.route("/")
def index():
    return jsonify({
        "app": "FedRAMP DevSecOps Demo",
        "version": "1.0.0",
        "environment": os.environ.get("APP_ENV", "development")
    })


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)

Here is the Dockerfile:

FROM python:3.12-slim@sha256:YOUR_PINNED_DIGEST AS base

LABEL maintainer="your-email@example.com"
LABEL org.opencontainers.image.source="https://gitlab.com/YOUR_USERNAME/devsecops-fedramp-pipeline"
LABEL org.opencontainers.image.description="FedRAMP-aligned DevSecOps demo application"

RUN adduser --disabled-password --gecos '' appuser

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir --no-compile -r requirements.txt

COPY app.py .

USER appuser

EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')" || exit 1

CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "app:app"]
Push and confirm reduced findings

Your code is clean and your container is hardened. Time to push the changes and let GitLab's scanners confirm.

  • Stage, commit, and push all your changes by running these commands:
git add .
git commit -m "Harden container and fix vulnerabilities"
git push

Before you check the pipeline results, what do you predict will happen to the security findings? Do you think the SAST, dependency, and container scanning jobs will report zero findings, or will some remain?

  • Navigate to your GitLab project in the browser.
  • Go to CI/CD in the left sidebar, then click Pipelines.
  • Click the latest pipeline (triggered by your push).
  • Click the Security tab on the pipeline.

The SAST finding for the hardcoded connection string is gone. The dependency scanning vulnerabilities from Jinja2 are gone. The container scanning job reports dramatically fewer CVEs because your pinned slim image has a minimal attack surface.

Will I see zero findings?

You may still see a small number of low-severity CVEs from the base operating system packages in python:3.12-slim. That is normal. The point is that you eliminated all the findings YOU introduced (the vulnerable dependency, the hardcoded secret) and reduced the container's attack surface dramatically.

Pipeline failing on the build job?

If the build job fails with a digest-related error, your FROM line may still contain the placeholder text YOUR_PINNED_DIGEST. Replace it with the real digest from your docker pull output, commit, and push again.

help me fix my GitLab pipeline build failure

Your code is clean and your container is locked down. Next up, you deploy this hardened image to your local Kubernetes cluster and document how your pipeline maps to NIST 800-53 controls.

Deploy to Kubernetes and Map Controls

Your hardened container image is sitting in the GitLab Container Registry with a clean security report. But a secure image in a registry proves nothing to a recruiter unless it actually runs somewhere.

In this step, you deploy your hardened container to a local Kubernetes cluster. Then you create a compliance mapping document that ties every pipeline stage to a specific NIST 800-53 control. This is what demonstrates the full lifecycle to anyone reviewing your project.

In this step, get ready to:
  • Create a Kubernetes deployment manifest with security-focused configurations.
  • Deploy your hardened container to minikube and verify it serves traffic.
  • Create a NIST 800-53 control mapping document for FedRAMP alignment.
Create the Kubernetes deployment manifest

A Kubernetes Deployment tells the cluster what container to run, how many replicas to keep alive, and what security boundaries to enforce. You also need a Service to expose the container's port so you can reach it from outside the cluster.

  • Create a k8s/ directory inside your devsecops-fedramp-pipeline project folder by running:
mkdir k8s
  • Create a new file called k8s/deployment.yaml and add the Deployment definition by pasting this code:
apiVersion: apps/v1
kind: Deployment
metadata:
  name: devsecops-app
  labels:
    app: devsecops-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: devsecops-app
  template:
    metadata:
      labels:
        app: devsecops-app
    spec:
      securityContext:
        runAsNonRoot: true
      containers:
        - name: app
          image: registry.gitlab.com/[[GITLAB_USERNAME="your GitLab username"]]/devsecops-fedramp-pipeline:latest
          ports:
            - containerPort: 8080
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            runAsNonRoot: true

What does this code do?

This defines a Deployment that runs one replica of your hardened container image from the GitLab Container Registry.

  • runAsNonRoot: true at the pod level enforces that no container in the pod can run as root. This mirrors what your Dockerfile already sets with the USER appuser directive.
  • readOnlyRootFilesystem: true prevents the container from writing to the filesystem at runtime. This blocks attackers from dropping malware even if they get code execution.
  • allowPrivilegeEscalation: false prevents a process inside the container from gaining more privileges than its parent.
  • Directly below the securityContext block, add the environment variables, resource limits, and health probes:
          env:
            - name: APP_ENV
              value: "production"
          resources:
            limits:
              memory: "128Mi"
              cpu: "250m"
            requests:
              memory: "64Mi"
              cpu: "125m"
          livenessProbe:
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 30
          readinessProbe:
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
      imagePullSecrets:
        - name: gitlab-registry

What does this code do?

  • resources sets hard ceilings on memory and CPU. This satisfies FedRAMP's least functionality principle by preventing a single pod from starving the cluster.
  • livenessProbe hits your /health endpoint every 30 seconds. If it fails, Kubernetes restarts the container automatically.
  • imagePullSecrets tells Kubernetes to use a secret named gitlab-registry to authenticate with the GitLab Container Registry when pulling your image.
  • Below the imagePullSecrets block, add the YAML document separator and the Service definition:
---
apiVersion: v1
kind: Service
metadata:
  name: devsecops-app
spec:
  type: NodePort
  selector:
    app: devsecops-app
  ports:
    - port: 80
      targetPort: 8080

What does the Service do?

The Service exposes your Deployment to network traffic. NodePort makes the service reachable on a port on the minikube node, so you can access your app from your browser. Traffic arriving on port 80 gets forwarded to your container's port 8080.

  • Save k8s/deployment.yaml.
  • Confirm the file exists by running:
ls k8s/

You should see deployment.yaml listed in the output.

Don't see the file?

  • Make sure you ran mkdir k8s before creating the file.
  • Check that your terminal is inside the devsecops-fedramp-pipeline folder.

Help me create the k8s directory and deployment file

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Your k8s/deployment.yaml should contain three sections in this order:

  • The Deployment definition (metadata, selector, pod template with securityContext, container image, and container-level securityContext).
  • The container configuration (env, resources, livenessProbe, readinessProbe, imagePullSecrets).
  • The Service definition (after the --- separator, type NodePort, port 80 to targetPort 8080).

Scroll up and confirm each chunk is present in the right order. Make sure your GitLab username replaces the placeholder in the image field.

Deploy to minikube

Your manifest is ready. Before Kubernetes can pull the image from GitLab's registry, it needs credentials stored as a Secret in the cluster.

  • In GitLab, go to your project's Settings > Repository > Deploy tokens.
  • Click Add token.
  • Enter k8s-pull as the name.
  • Check the read_registry scope.
  • Click Create deploy token.
  • Copy the token value and the username (it will look like gitlab+deploy-token-123456). You need both for the next command.
  • Create the image pull secret in your minikube cluster by running this command:
kubectl create secret docker-registry gitlab-registry \
  --docker-server=registry.gitlab.com \
  --docker-username=your-deploy-token-username \
  --docker-password=your-deploy-token-value

What does this command do?

This creates a Kubernetes Secret named gitlab-registry that stores your GitLab registry credentials. When a pod references this secret in imagePullSecrets, Kubernetes uses it to authenticate with registry.gitlab.com and pull your container image.

You should see secret/gitlab-registry created in your terminal output.

Seeing an "already exists" error?

If you ran the command before and need to recreate it, delete the existing secret first:

kubectl delete secret gitlab-registry

Then run the create command again.

Help me fix my kubectl secret creation

  • Deploy your manifest to minikube by running:
kubectl apply -f k8s/deployment.yaml

You should see two resources created: deployment.apps/devsecops-app created and service/devsecops-app created.

Before you check the pod status, what do you think kubectl get pods will show? Will the pod be Running immediately, or might it need time to pull the image?

  • Check the pod status by running:
kubectl get pods

You should see a pod named devsecops-app- followed by a random suffix with status Running. If it shows ContainerCreating, wait 30 seconds and run the command again.

Pod stuck in ImagePullBackOff?

  • Double check that your deploy token has the read_registry scope enabled.
  • Verify the image name in k8s/deployment.yaml matches your actual GitLab registry path. Check it against what you see in your GitLab project under Deploy > Container Registry.
  • Make sure the deploy token username and value are correct in your secret. Delete and recreate if needed.

Help me fix my pod's ImagePullBackOff status

Now let's test whether your app actually responds to requests. Do you expect the health endpoint to return the same JSON you saw locally?

  • Get the service URL by running:
minikube service devsecops-app --url
  • Copy the URL from the output and test the health endpoint by running:
curl $(minikube service devsecops-app --url)/health

You should see {"service":"devsecops-demo","status":"healthy"} in the response. Your hardened container is running in Kubernetes and serving traffic.

Create the NIST 800-53 control mapping document

The pipeline is running and the app is deployed. The final piece is documentation that ties everything together for a compliance reviewer or recruiter. This document maps each pipeline stage to the specific NIST 800-53 control it satisfies.

  • Create a docs/ directory by running:
mkdir docs
  • Create a new file called docs/COMPLIANCE_MAPPING.md and add the header and control mapping table:
# NIST 800-53 Control Mapping - DevSecOps Pipeline

This document maps CI/CD pipeline stages to NIST SP 800-53 Rev 5 controls
for FedRAMP alignment.

## Pipeline Stage to Control Mapping

| Pipeline Stage | NIST Control | Control Name | Evidence |
|---|---|---|---|
| SAST (Semgrep) | SA-11 | Developer Testing and Evaluation | gl-sast-report.json |
| Secret Detection | SI-7 | Software, Firmware, and Information Integrity | gl-secret-detection-report.json |
| Dependency Scanning | SR-3, SR-4 | Supply Chain Controls and Provenance | gl-dependency-scanning-report.json |
| Container Scanning | CM-7 | Least Functionality | gl-container-scanning-report.json |
| Hardened Dockerfile | CM-6 | Configuration Settings | Dockerfile (pinned base, minimal packages) |
| Non-root Container | AC-6 | Least Privilege | Dockerfile USER directive, K8s securityContext |
| Image Registry | CM-2 | Baseline Configuration | GitLab Container Registry with tagged images |
| Pipeline Audit Trail | AU-2 | Event Logging | GitLab CI/CD pipeline history |

What are these control codes?

Each code (SA-11, CM-7, etc.) is a specific security control from NIST Special Publication 800-53 Revision 5. FedRAMP uses these controls as its baseline for authorizing cloud systems.

By mapping your pipeline stages to controls, you demonstrate that your DevSecOps pipeline produces real compliance evidence. The JSON report artifacts are the machine-readable proof.

  • Below the table, add the FedRAMP container security checklist and the continuous monitoring section:

## FedRAMP Container Security Checklist

- [x] Hardened base image (python:3.12-slim, pinned by digest)
- [x] Non-root user execution
- [x] No embedded secrets or credentials
- [x] Vulnerability scanning before deployment
- [x] Minimal installed packages
- [x] Read-only root filesystem in Kubernetes
- [x] Resource limits enforced
- [x] Health checks configured

## Continuous Monitoring

Pipeline runs automatically on every push, providing continuous security
assessment as required by FedRAMP for ongoing authorization (CA-7).

Why does this checklist matter?

Every checkbox item corresponds to something you actually implemented in this project. A recruiter or reviewer can read this checklist, then verify each claim by looking at your Dockerfile, your deployment manifest, or your pipeline configuration. This turns your project from "I followed a tutorial" into "I understand compliance."

  • Save docs/COMPLIANCE_MAPPING.md.
  • Push all new files to GitLab by running:
git add . && git commit -m "Add K8s deployment and compliance mapping" && git push
  • Navigate to CI/CD > Pipelines in your GitLab project.

You should see a green passing pipeline. Your project now contains the Kubernetes manifest and compliance documentation alongside your pipeline configuration.

Pipeline failing?

  • The pipeline scans your code and container image. If the build job fails, make sure your Dockerfile has no syntax errors by running docker build -t test . locally.
  • If a security scan job fails (not just reports findings), check that you did not accidentally remove the test stage from your .gitlab-ci.yml stages list.

Help me debug my GitLab pipeline failure

✔️ Awesome, I've got everything!

Great. Double check that docs/COMPLIANCE_MAPPING.md is saved and pushed.

ⓧ I'd like to double check the full code

Your docs/COMPLIANCE_MAPPING.md should contain three sections in this order:

  • The header and Pipeline Stage to Control Mapping table (8 rows mapping SAST, Secret Detection, Dependency Scanning, Container Scanning, Hardened Dockerfile, Non-root Container, Image Registry, and Pipeline Audit Trail).
  • The FedRAMP Container Security Checklist (8 checked items).
  • The Continuous Monitoring section referencing CA-7.

Scroll up and confirm each chunk is present in the right order.

Your project now has the full lifecycle: secure code, hardened container, Kubernetes deployment, and compliance documentation. Anyone reviewing your GitLab project can see green pipelines, read your control mapping, and verify every claim in the checklist. Next up, you put your support engineer skills to the test with a real pipeline failure.

Secret mission

Troubleshoot a Pipeline Failure

Support engineers at GitLab debug broken pipelines every day. In this mission, you simulate that exact scenario: a developer commits a secret, the pipeline catches it, and you diagnose and remediate the leak from the pipeline logs alone.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely on free tools and services (GitLab.com free tier, Docker Desktop, and minikube locally), so there are no ongoing financial costs.

Resources you used:

  • minikube cluster running locally (free, uses Docker resources and ~2-3GB RAM).
  • GitLab project and Container Registry images (free tier, unlimited for public projects).
  • GitHub mirror repository (free).
  • Local project folder at ~/Desktop/devsecops-fedramp-pipeline.

Keep everything running

No action needed. Choose this if you are still actively building on this project or plan to use it as a reference.

  • Your minikube cluster will continue running and consuming ~2-3GB of RAM through Docker Desktop.
  • Docker Desktop must remain running for minikube to stay active.
  • Your GitLab pipeline will continue running scans on every push at no cost.
  • The push mirror to GitHub will keep syncing automatically.

Pause - I'll come back to this later

Shut down your local Kubernetes cluster to free up RAM and CPU. Your cluster state is preserved so you can resume later.

  • Stop the minikube cluster by running:
minikube stop
  • This frees up RAM and CPU immediately. Your cluster configuration, deployments, and secrets are saved to disk.
  • To resume later, run:
minikube start
  • Your GitLab and GitHub repositories stay as-is. No action needed for those.

Delete - I don't want to use this again

Remove all project resources from your machine and cloud accounts. This is irreversible.

  • Delete the minikube cluster entirely by running:
minikube delete
  • Delete the local project folder by running:
rm -rf ~/Desktop/devsecops-fedramp-pipeline
  • Delete the GitLab project: open your devsecops-fedramp-pipeline project on GitLab.com, go to Settings in the left sidebar, select General, expand the Advanced section, and select the option to delete the project.
  • Delete the GitHub mirror repository: open the repository on GitHub, go to Settings, scroll to the bottom, and select the option to delete the repository.
  • Deleting the GitLab project also removes all Container Registry images associated with it.

That's a Wrap!

That's a Wrap!

Nice work. You just built a production-grade DevSecOps pipeline on GitLab that scans, hardens, and deploys a containerized application to Kubernetes.

You've learned how to:

  • Create a GitLab CI/CD pipeline with four integrated security scanners (SAST, Secret Detection, Dependency Scanning, Container Scanning) that produce real vulnerability reports on every push.
  • Harden a container image following FedRAMP practices (pinned base image, non-root user, minimal packages) and deploy it to a local Kubernetes cluster with security-focused configurations.
  • Map pipeline stages to NIST 800-53 controls (SA-11, SR-3, SR-4, CM-6, CM-7, SI-7, AC-6) and set up push mirroring from GitLab to GitHub for portfolio visibility.
  • Secret Mission: Diagnosed and remediated a leaked secret using support engineer troubleshooting skills, including history rewriting with git filter-repo and historic scanning verification.

Ready to quiz yourself?