//PART 1 OF 10
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
- 01Part 1: Why I Built a Secure CI/CD Pipeline, and Getting the Tools Ready
- 02Part 2: Vault Secrets, Policies, and an AppRole per Environment
- 03Part 3: A Small Go App That Reads Its Secrets from a File and Refuses to Start on Bad Config
- 04Part 4: A Vault Agent Sidecar and Docker Compose, Secrets Without the App Knowing Vault Exists
- 05Part 5: The GitHub Repo and the PR Workflow as a Security Gate
- 06Part 6: Rulesets That Block Merges, and Five Demo PRs That Must Fail
- 07Part 7: Setting Up the Dev and Production Environments in Dokploy
- 08Part 8: The Deploy Workflow, Build Once, Scan, SBOM, GHCR, Then Promote to Production
- 09Part 9: An Exception Process for Security Findings You Can't Fix Yet
- 10Part 10: CI Gets Its Dokploy Token from Vault through GitHub OIDC, and Wrapping Up the Series
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:
A PR containing a secret, a vulnerable dependency, dangerous code, or a bad Dockerfile is proven to be blocked.
A merge into
developmentdeploys to dev automatically. A merge intomaindeploys to production after an approval.Not a single secret value in the public repo.
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:
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 mergeFor secrets while the app is running:
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:
Stage A (Part 8) : GitHub Environment Secret DOKPLOY_API_KEYStage B (Part 10): GitHub OIDC token → Vault (JWT auth) → Dokploy tokenStage 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 |
|
Flow |
|
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 |
| A file isn't formatted, vet errors, or a test fails |
Secret scanning | Gitleaks | Any secret is found |
SAST | Semgrep CE ( | 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 |
| A PR into |
Production | GitHub Environment | Not manually approved yet |
Smoke test |
| 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:
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
Overview and tool setup (this post)
Vault: secrets, policies, and an AppRole per environment
A small Go app that reads its secrets from a file
Vault Agent sidecar and Docker Compose
GitHub repo and the PR workflow as a security gate
Rulesets that block merges, plus demo PRs that fail
Dokploy: dev and production environments
The deploy workflow: build, scan, SBOM, GHCR, Dokploy
An exception process for security findings
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:
go version # I'm on go1.27docker versiongh --versionThen 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.
brew install hashicorp/tap/vaultbrew install trivy gitleaks semgrep syftLog in to the GitHub CLI. It's interactive, so run it yourself:
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.
vault versiontrivy --versiongitleaks versionsemgrep --versionsyft versionDNS 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 |
|
|
A |
|
|
DNS can take a few minutes to propagate. Check with dig:
dig +short secure-cicd-dev.yourdomain.com # → VPS IPdig +short secure-cicd.yourdomain.com # → VPS IPHTTPS 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.