Part 1: Why I Built a Secure CI/CD Pipeline, and Getting the Tools Ready

· 8 min read

Indonesia
//TUTORIAL · 1 OF 10 — A Secure CI/CD Pipeline with Go, GitHub Actions, Vault, and Dokploy

In a lot of teams I've seen, the path from a commit to production depends on people remembering things. Remember to run the scanner. Remember not to commit .env. Remember to check dependencies. The one time someone forgets, it slips through.

In this series I want to flip that. Security doesn't run because someone remembered. It runs because the platform forces it. If something is wrong, the PR can't be merged. Period.

I called the project secure-cicd-go. The repo is public at github.com/dimss113/secure-cicd-go, so you can look at the finished result while you follow along.

Note

The Go app is deliberately tiny. The "product" here is the pipeline, not the app.

What I want to prove

I want to show that security can be wired into a developer's workflow automatically, from pull request to deploy, using only free or open source tools:

  • A developer opens a PR, and the code is checked automatically: tests, secrets, SAST, dependencies, the Dockerfile, and the image.

  • If anything is wrong, the PR can't be merged.

  • If it passes, the image is built once, scanned, given an SBOM, pushed to a registry, and then that exact image is deployed to Dokploy.

  • The app never keeps a secret in the repo. Secrets come from HashiCorp Vault through Vault Agent.

I wrote down what success looks like up front, so it's clear when the project is done:

  1. A PR containing a secret, a vulnerable dependency, dangerous code, or a bad Dockerfile is proven to be blocked.

  2. A merge into development deploys to dev automatically. A merge into main deploys to production after an approval.

  3. Not a single secret value in the public repo.

  4. Someone else can reproduce the setup from the README without asking me.

I also wrote down the anti-goals, the things that make this a failure even if it "works":

  • The pipeline can be bypassed, for example an admin can push straight to main.

  • A secret shows up in a CI log.

  • The demo AppRole can read another app's secrets.

The big picture

This is the branch flow:

text
feature/*  ──PR──▶  development  ──PR──▶  main   │                     │                   │   │  [PR checks]        │ push              │ push   │  test, gitleaks,    ▼                   ▼   │  semgrep,      [deploy.yml]        [deploy.yml]   │  govulncheck,  build image         find the dev image   │  trivy config, trivy image         ⏸ manual approval   │  trivy image   SBOM (Syft)         promote to production   │                push to GHCR        smoke test   │                deploy dev   │                smoke test   ▼failure = can't merge

For secrets while the app is running:

text
Dokploy (env: VAULT_ROLE_ID, VAULT_SECRET_ID)  → vault-agent container logs in to Vault with AppRole  → renders a template → config.json on a tmpfs volume (RAM)  → app container reads config.json (read-only)

And for the secret CI uses to deploy, there are two stages:

text
Stage A (Part 8) : GitHub Environment Secret DOKPLOY_API_KEYStage B (Part 10): GitHub OIDC token → Vault (JWT auth) → Dokploy token

Stage A comes first on purpose, to get the pipeline working. In the last part I delete that GitHub secret and replace it with OIDC, so GitHub stores no credential at all.

Decisions I made up front

Topic

Decision

App

A new Go project, as simple as possible

Repo

Public. Rulesets that block merges, the Security / code scanning tab, and environment approvals are free only for public repos

Branches

development is the default branch and deploys to dev. main deploys to production

Flow

feature/* → PR into development → PR development → main

Registry

GHCR (GitHub Container Registry), free

Deploy

Dokploy, Compose type. Dokploy's auto-deploy is off: only CI may trigger a deploy

Runtime secrets

A Vault Agent sidecar that renders a template to a file

Environment isolation

Separate AppRoles and policies for dev and production. The dev role can't read production secrets

Tools

All free or open source

There are also things I deliberately left out for now, so the scope doesn't grow:

Not doing

Why

Snyk

Not open source, and the free quota is limited

Cosign (image signing), deploy by digest

That's its own project: container supply-chain security

DAST (OWASP ZAP)

Later, once the dev environment is stable

Kubernetes, AWS, Terraform

Other projects

A database

Not needed to demo a pipeline

Security gates: when a PR fails

This is the heart of the project. Every row here becomes a required status check, so a PR can't be merged if any of them is red.

Check

Tool

Fails when

Format & tests

gofmt, go vet, go test -race

A file isn't formatted, vet errors, or a test fails

Secret scanning

Gitleaks

Any secret is found

SAST

Semgrep CE (p/golang, p/default, p/secrets)

An ERROR-severity finding

Dependencies (SCA)

govulncheck

A vulnerability the code actually calls

New dependencies

dependency-review-action

A new dependency with a High/Critical vuln

Dockerfile

Trivy config

A HIGH/CRITICAL misconfig, such as running as root

Container image

Trivy image

A CRITICAL CVE that already has a fix

SBOM

Syft (CycloneDX JSON)

The SBOM can't be generated, so the deploy fails too

Source branch into main

source-branch workflow

A PR into main that doesn't come from development

Production

GitHub Environment production

Not manually approved yet

Smoke test

curl /version

The running version differs from the deployed commit

Tip

Build once, promote to production

The image is only built on a push to development, tagged sha-<commit>. When development is merged into main, the pipeline doesn't rebuild. It finds the sha-<commit> image that already ran in dev and adds the production tag to that same image. What runs in production is an image that was already scanned and tested in dev, not a fresh build nobody has tested.

The final repo layout

So you can picture it, this is the repo once every part is done:

text
secure-cicd-go/├── cmd/api/main.go              # entrypoint├── internal/                    # handler, config, jwt, aes├── Dockerfile                   # multi-stage, non-root, pinned base├── docker-compose.development.yml├── docker-compose.production.yml├── vault-agent/│   ├── Dockerfile│   ├── agent.hcl                # approle auto_auth + template│   └── config.json.ctmpl        # template: secret paths only├── vault/│   ├── policies/                # Vault policies, reviewed through PRs│   └── roles/                   # jwt-github roles (Part 10)├── .github/workflows/│   ├── pr-checks.yml│   ├── source-branch.yml│   └── deploy.yml├── .gitleaksignore              # Gitleaks exceptions (Part 9)├── .trivyignore                 # CVE exceptions with an expiry date├── scripts/check-exceptions.sh  # enforces the exception rules└── docs/

Series map

  1. Overview and tool setup (this post)

  2. Vault: secrets, policies, and an AppRole per environment

  3. A small Go app that reads its secrets from a file

  4. Vault Agent sidecar and Docker Compose

  5. GitHub repo and the PR workflow as a security gate

  6. Rulesets that block merges, plus demo PRs that fail

  7. Dokploy: dev and production environments

  8. The deploy workflow: build, scan, SBOM, GHCR, Dokploy

  9. An exception process for security findings

  10. CI gets its Dokploy token from Vault through OIDC

Tool setup

Now the hands-on part. The goal is simple: every tool we need is on the laptop. I'm on a Mac, so the examples use Homebrew.

First, check what's usually already there:

bash
go version       # I'm on go1.27docker versiongh --version

Then install the Vault CLI and the scanners. They'll run in CI later, but I install them locally too so I can try things before pushing. It's much faster than waiting for CI to go red over and over.

bash
brew install hashicorp/tap/vaultbrew install trivy gitleaks semgrep syft

Log in to the GitHub CLI. It's interactive, so run it yourself:

bash
gh auth login     # choose GitHub.com → SSH → log in with the browsergh auth status    # should say: Logged in to github.com account <username>

Check: every one of these should print a version.

bash
vault versiontrivy --versiongitleaks versionsemgrep --versionsyft version

DNS for dev and production

I need two domains, one per environment. In your domain's DNS panel, add two A records pointing at the VPS that runs Dokploy:

Type

Name

Value

A

secure-cicd-dev

<VPS_IP>

A

secure-cicd

<VPS_IP>

DNS can take a few minutes to propagate. Check with dig:

bash
dig +short secure-cicd-dev.yourdomain.com   # → VPS IPdig +short secure-cicd.yourdomain.com       # → VPS IP

HTTPS is handled later by Traefik and Let's Encrypt inside Dokploy, so A records are all we need here.

Recap

No code yet in this part. What we have:

  • A clear goal, success criteria, and anti-goals.

  • The branch flow and the secret flow.

  • The list of security gates that will become required checks.

  • Tools on the laptop and two DNS records.

In Part 2 we start with the most important foundation: setting up secrets, policies, and AppRoles in Vault, then proving that the dev credential really can't read production secrets.

Related articles