An app deployed on Vercel and Supabase ships no container image and no infrastructure code of its own, so most pipeline security advice scans things it does not have. CI CD security best practices at this size come down to 5 controls: a read-only default token, actions pinned to a commit SHA, secrets scoped per environment, a protected deploy path, and two scans.
CI CD security best practices: the 5 controls, and the checklist
CI CD security best practices for a small app come down to 5 controls: a read-only default workflow token with per-job permissions, third-party actions pinned to a full commit SHA, secrets scoped to each environment that fork pull requests do not receive, a protected production branch as the only road to production, and two scans on every pull request.
This page is the security part of DevOps for startups, the guide to a small team’s release path. The security risks associated with CI/CD come from what a pipeline holds: it keeps deploy credentials, runs third-party code on every push, and can write to production, so CD security, the deploy half, matters as much as the build half.
Who deploys decides where the gate sits. A host connected through its Git integration deploys on its own: Vercel for GitHub says it “will deploy every push by default”, and where custom domains are set, pushes and merges to the Production Branch are made live on them. On that default path the deploy gate is the production branch and the host’s own settings. My reading is that a deploy job in GitHub Actions gates nothing there unless the host’s automatic deploys are turned off (Vercel’s git.deploymentEnabled setting) and the workflow deploys instead.
Control 4 has two halves. The branch half is GitHub branch protection, and “the only road” is my working rule: on Vercel it also means knowing who can run vercel --prod, which Vercel names beside a push to the Production Branch as one way to create a Production Deployment. The deploy half depends on who deploys. Where the host deploys, Vercel’s Deployment Checks hold each production deployment until all required checks pass before assigning it to the custom production domains; the deployment itself is still created. Where an Actions job deploys, the gate is a protected GitHub environment.
| Control | The risk it closes | Where it is set | What it needs on a private repository (checked 2026-09-28) | The test |
|---|---|---|---|---|
| 1. Read-only default token, per-job grants | A hijacked step inherits write access to the repository | Settings, Actions, General, “Workflow permissions”; the permissions: key in each workflow | No plan condition printed on GitHub’s settings page | Check 1 |
| 2. Every third-party action pinned by SHA | A moved tag runs code nobody reviewed | Each uses: line; the pin policy under “Actions permissions” in the same settings page | None printed there either | Check 2 |
| 3. Secrets in scoped stores, none for forks | A secret reaches code it was never meant for | Host deploys: the host’s environment variables. Workflow deploys: a GitHub environment | GitHub environment secrets: GitHub Pro, Team or Enterprise (on GitHub Free, “only available in public repositories”). Host variables: the host’s own plan page | Checks 3 and 4 |
| 4. Protected production branch as the only road, plus the deploy gate | An unreviewed change or a stray deploy reaches users | The branch rule in repository settings; Vercel’s Deployment Checks where the host deploys; a protected environment where a workflow deploys | Protected branches: GitHub Pro, Team, Enterprise Cloud or Enterprise Server. Environment deployment branches: GitHub Pro, Team or Enterprise. Required reviewers: public repositories only on Free, Pro and Team. Vercel Deployment Checks: no plan condition printed | Check 5 |
| 5. Two scans on every pull request | A vulnerable package or a committed key reaches the main branch | Workflow steps; push protection in the repository’s security settings | Dependency audit: none, it is a workflow step. Push protection for repositories: GitHub Secret Protection, available on GitHub Team and Enterprise Cloud | Checks 6 and 7 |
The protected-branch cell comes from GitHub’s protected branches, with the plans that include them, the environment cells from GitHub’s deployments reference and its guide to managing environments, and the push protection cell from GitHub’s Advanced Security page, which says running it on a private repository means buying the product. On a GitHub Free private repository deployed by Vercel, my reading is that Vercel’s Deployment Checks are the gate that remains: GitHub Free is on Vercel for GitHub’s list of supported products, and the Deployment Checks page prints no plan condition.
Kept as a CI CD security checklist, the table needs two more columns: the date each row was last checked and who owns it. A security CI CD pipeline, in these terms, is one where all five rows hold. For CI/CD and build security, the rows split by stage: rows 1, 2 and 5 guard the build, rows 3 and 4 guard the deploy.
What does CI/CD security mean?
CI/CD security means protecting the pipeline that builds, tests and deploys the app: who can change it, which third-party code it runs, which secrets it holds, and what it may push to production. The pipeline is a target because it holds deploy credentials and runs on every push.
CI is the half that builds and tests every change; CD is the half that deploys it. An attacker wants one of four things from that pair: write access to the repository, the secrets the runner holds while a job runs, a third-party action or package to swap for their own, or the deploy credential itself. The OWASP Top 10 CI/CD Security Risks, mapped further down, names ten risks across that ground.
What a managed-platform app does not have to scan
A managed-platform app is missing four things most pipeline security advice assumes: its own container images, infrastructure code, a cloud account of its own, and a cluster. Those scanners have nothing to read here. The platforms’ own settings still need a review, done in each dashboard and written down.
The table is my reading of a stack like Vercel plus Supabase, and it holds until the day a Dockerfile, a Terraform folder or a cloud account appears in the project. The vocabulary section further down covers that day.
| Scan type | What it scans | Does a Vercel or Supabase app have that thing? | What to do instead |
|---|---|---|---|
| Container image scanning | Packages inside an image you build | No: the platform builds and runs the code | Audit dependencies in CI (control 5) |
| Infrastructure-as-code scanning | Terraform or CloudFormation files | No: the settings live in dashboards | Review the dashboard settings and record them |
| Cloud posture scanning | Settings across a cloud account | No: there is no cloud account of your own | Review each platform’s security settings |
| Kubernetes checks | A cluster’s configuration | No: there is no cluster | Nothing until a cluster exists |
A secure cloud deployment at this size is the five controls plus that settings review. Secure cloud infrastructure in the enterprise sense assumes accounts you operate yourself, and an infrastructure security assessment likewise assumes infrastructure you run, so neither applies to a stack whose infrastructure belongs to the platforms.
Why it matters: GitHub Actions security after tj-actions/changed-files
The tj-actions/changed-files GitHub Action was compromised on March 14 and 15, 2025: its version tags were changed to point at malicious code that printed CI secrets into the workflow logs, readable by anyone on repositories with public logs. A version tag can be moved that way; a full commit SHA cannot.
Supply chain and deployment are also among the weakest areas in the apps I audited. In my June and July 2026 audits of third-party apps, 11 public vibe-coded apps audited exhaustively across all 12 pillars plus a held-out set of 10 apps audited blind, the Dependencies & Supply Chain pillar averages 34.5 out of 100 across the 20 apps scored on it, and Deployment & Operations averages 37.0 across all 21, ranked 2nd and 3rd of the 12 pillars counting from the weakest. Those apps are a selected set, not a random sample, so the scores describe the apps I audited and not AI-built apps in general. The method and the full numbers are in the statistics from my 26 app audits.
The GitHub advisory for tj-actions/changed-files is titled “tj-actions changed-files through 45.0.7 allows remote attackers to discover secrets by reading actions logs” and was published on March 15, 2025 as CVE-2025-30066. In CISA’s words, the action “is designed to detect which files have changed in a pull request or commit”, so a workflow calls it to get that list. Attackers retroactively modified multiple version tags to reference a malicious commit, and the malicious code extracted secrets from the runner process’s memory and printed them in the workflow logs, “making them publicly accessible in repositories with public workflow logs”. The advisory puts the window at March 14 and 15, 2025, names 46.0.1 as the patched version, and counts the impact as “over 23,000” repositories. CISA’s alert on tj-actions and reviewdog, last revised March 26, 2025, added on March 19 that the compromise “was potentially enabled by a compromise of another GitHub Action, reviewdog/action-setup@v1”.
A tag pin did not help because a tag is a pointer the action’s owner, or anyone who takes over their repository, can move. GitHub’s secure use reference says pinning to a full-length commit SHA “is currently the only way to use an action as an immutable release.”
What changed in practice, as my working rule, is five habits.
- Pin every third-party action by its full commit SHA, and keep the version in a comment beside it so a person can still read what it is.
- Turn on the policy that enforces the pin. GitHub offers it at repository and organization level, and with “Require actions to be pinned to a full-length commit SHA” on, “all actions must be pinned to a full-length commit SHA to be used. This includes actions from your organization and actions authored by GitHub.” Reusable workflows can still be referenced by tag. My reading of that sentence: GitHub’s own actions, such as checkout, need their pins before the policy goes on, or every workflow that uses them stops.
- Know what a pin does not cover. My reading is that a pin fixes the action’s own code, and covers what the action fetches at run time only if the action pins its own downloads too; the Trivy supply chain attack is that case.
- Let an update bot raise pin bumps as pull requests someone reviews; setting one up is a separate topic, starting with what Renovate bot is.
- Restrict which actions may run at all, from the allowed-actions list in GitHub’s Actions settings for a repository.
My working rule is to treat any secret present in a workflow run inside an exposure window as leaked, which matches CISA’s advice to rotate every identified secret immediately, as they “should be considered compromised”. After rotating, run secret scanning over the history so an old copy does not outlive the new key. AI assistants open their own paths into a repository, and those are covered under GitHub Copilot security risks.
Is GitHub safe, and what GitHub spoofing is
GitHub is a legitimate code host, and anyone can publish a repository on it, so trust the publisher, not the domain. Git lets a commit carry any author name and email, which is how GitHub spoofing works; a signed commit GitHub marks Verified, with vigilant mode on for the authors it names, is the check to rely on.
Two different readers ask whether GitHub is safe. Someone downloading a tool from GitHub is asking about the publisher: the site is real, but a repository is only as trustworthy as the account behind it. Someone hosting their company’s code there is asking about GitHub cybersecurity for their own team, and at this size that is account security: two-factor authentication on every member and no personal access token without an expiry, as my working rule.
Anyone can spoof a GitHub commit author, because, in GitHub’s own words, “Git allows you to set the author of your changes and the identity of the committer.” A commit can therefore appear to come from a colleague who never wrote it. Signing is the answer: GitHub marks a commit “Verified” when its GPG, SSH or S/MIME signature checks out, and GitHub’s guide to commit signature verification explains the statuses. Vigilant mode is the stricter setting, and GitHub says that “By default vigilant mode is not enabled.” With it on, a signed commit whose author is not the committer, and who has vigilant mode on, shows “Partially verified”, because “the commit signature doesn’t guarantee the consent of the author, so the commit is only partially verified.” The other form of spoofing is a look-alike repository with a near-identical name, which is one more reason to check the publisher before you install anything.
How it works: the workflow file, line by line
A hardened GitHub Actions workflow differs from a default one in 4 places: a read-only permissions block at the top, per-job grants, uses lines pinned to full commit SHAs, and no pull_request_target trigger. When the workflow itself deploys, its deploy job is also bound to a protected environment, which a private repository gets on a paid GitHub plan.
Which part applies depends on who deploys. On a host that deploys every push itself, this file is the checks workflow and the deploy stays with the host. The deploy job at the bottom is only for teams whose workflow deploys, which is my reading of Vercel’s own split between its Git integration and its GitHub Actions route.
# .github/workflows/checks.yml
# Every SHA and version below is a placeholder: copy the real 40-character SHA
# from the action's own repository and keep its version as the comment.
name: checks
on:
pull_request:
push:
branches: [main] # production branch: its merge commit gets checks too
permissions:
contents: read # read-only default for every job
jobs:
checks-test: # unique across workflows: required checks match by job name
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<40-character-commit-SHA> # vX.Y.Z
- uses: actions/setup-node@<40-character-commit-SHA> # vX.Y.Z
- run: npm ci
- run: npm test
- run: npm audit --audit-level=high --omit=dev
# Variant, only for a workflow that deploys (the host's own auto-deploy off).
# Environments, their secrets and deployment branch rules need GitHub Pro,
# Team or Enterprise on a private repository; required reviewers need a
# public repository below Enterprise.
deploy:
if: github.event_name == 'push'
needs: checks-test
runs-on: ubuntu-latest
environment: production # secrets and branch rules live on the environment
steps:
- uses: actions/checkout@<40-character-commit-SHA> # vX.Y.Z
- run: ./scripts/deploy.sh # your host's deploy command
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} # read from env, not a flag
Vercel’s Deployment Checks read the “commit statuses and check run results” of the deployed commit, and a merge to the production branch makes a new commit, so my reading is that the push trigger is what gives that commit its checks. The job name matters because both GitHub branch protection and Vercel’s Deployment Checks identify required checks by job name, and GitHub asks for job names that are unique across all workflows. The deploy job needs no write grant, because it deploys with the host’s token rather than GITHUB_TOKEN; a job that signs in to a cloud provider through OpenID Connect would add id-token: write, and GitHub’s syntax reference names that permission for fetching the token. The file never uses pull_request_target, and the audit line is the dependency scan from the scans section below.
Least-privilege tokens and the permissions block
GITHUB_TOKEN should start with read access to repository contents, each job adding only the scopes it needs, which is GitHub’s own advice. GitHub warns that the pull_request_target trigger, combined with a checkout of an untrusted pull request, exposes the repository. OpenID Connect replaces cloud credentials stored as long-lived secrets where the provider supports it.
The repository-wide default lives in Settings, Actions, General, under “Workflow permissions”, where the choice is read and write access for all permissions or read access for the contents and packages permissions. A new repository in a personal account starts on the read-only setting, while one created in an organization inherits the organization’s setting, so read it rather than assume it. GitHub’s secure use reference for Actions puts the advice this way: “It’s good security practice to set the default permission for the GITHUB_TOKEN to read access only for repository contents.” Grants then go on the job that needs them, as the file above does.
The two triggers to handle with care are pull_request_target and workflow_run. GitHub says that, used “with the checkout of an untrusted pull request”, they “expose the repository to security compromises”. Its good practices come in order: avoid pull_request_target if it is not necessary, since “For privilege separation between workflows, workflow_run is a better trigger”; use neither “with untrusted pull requests or code content”; and workflows on these triggers “must not explicitly check out untrusted code, including from pull request forks or from repositories that are not under your control.”
Cloud credentials are the other half of least privilege. OpenID Connect in GitHub Actions lets a workflow exchange short-lived tokens with a cloud provider instead of storing its credentials as long-lived GitHub secrets, and GitHub’s guides cover AWS, Azure, Google Cloud Platform, HashiCorp Vault, JFrog, Octopus Deploy and PyPI. A host deployed from a workflow may take a stored token instead: Vercel’s own GitHub Actions example passes --token=${{ secrets.VERCEL_TOKEN }}. That token is the credential to scope as narrowly as the host allows and to give an expiry wherever the host offers one, as my working rule.
Secrets in CI
CI secrets belong in scoped stores: production keys in the host’s environment variables when the host deploys, or a GitHub environment when a workflow deploys, which private repositories get on GitHub Pro, Team or Enterprise. Redaction relies largely on an exact match, so a secret wrapped in JSON can slip through. Forks get no secrets but GITHUB_TOKEN by default.
Where production secrets live depends on who deploys, which is my reading of the two routes. With the host’s Git integration, they sit in the host’s environment variables for production, with separate values per environment, as a minimum safe deploy pipeline for a one-person team sets out. With a workflow that deploys, they sit in environment secrets on a GitHub environment. A private repository gets those only on a paid plan, per the table’s secrets row, and GitHub’s deployments and environments reference adds an approval gate: “If the environment requires approval, a job cannot access environment secrets until one of the required reviewers approves it.” Below Enterprise, required reviewers work only on public repositories (the table’s branch row again).
On GitHub Free with a private repository, secrets sit at repository level, and GitHub’s reference says “Any user with write access to your repository has read access to all secrets configured in your repository”. So keep the production deploy credential with the host wherever the host can deploy, as my working rule.
Redaction does not catch everything. GitHub says redaction largely relies on finding an exact match for the secret’s value, and that structured data such as a JSON blob holding a secret can make it fail. My working rule follows from that: never echo a secret, never pass it on a command line, and never write it to an artifact. GitHub’s guide makes the command-line point too, since command-line processes “may be visible to other users (using the ps command) or captured by security audit events”.
Forks get less by design. GitHub’s guide to using secrets says: “With the exception of GITHUB_TOKEN, secrets are not passed to the runner when a workflow is triggered from a forked repository.” On a private repository, the Fork pull request workflows option “Send secrets to workflows from pull requests”, which “Makes all secrets available to the pull request”, stays off. On Vercel, a pull request from a fork needs “authorization from you or a team member” before it deploys, which Vercel says protects environment variables, unless Git Fork Protection is turned off in the project’s Security settings.
The host’s deploy token gets the same treatment as in the tokens section above: scoped and expiring. After any suspect run, rotate first and investigate second. The wider picture, from where each key lives to who can read it, is secrets management for an AI-built app.
The two scans worth running, and the ones to skip for now
Two scans earn their noise: a dependency audit that fails the build on high-severity production findings, and a secret scan that stops a key before it lands, through GitHub’s push protection where the account pays for Secret Protection, otherwise a scanner in CI. A workflow linter is a cheap third. Static analysis waits until someone reads its findings weekly.
As my working rule, the first scan is a dependency audit on every pull request, with a threshold that fails the build on high severity in production dependencies; the audit line in the workflow file does that for npm, and the npm audit command covers its flags and output. The second is a secret scan that stops a key before it lands. GitHub’s push protection does it in two forms: push protection for repositories “Requires GitHub Secret Protection to be enabled”, while push protection for users “Stops you from pushing secrets to public repositories on GitHub”. GitHub’s Advanced Security page adds that “GitHub Code Security and GitHub Secret Protection are available for accounts on GitHub Team and GitHub Enterprise Cloud.” and that “To run the feature on your private or internal repositories, you must purchase the relevant GitHub Advanced Security product.” On a private repository without Secret Protection, my working rule is a secret scanner in CI or a pre-commit hook instead; which scanner, and what secret scanning costs, is the secret scanning guide’s topic.
A workflow linter is the third scan when there is time. zizmor, an open-source static analysis tool for GitHub Actions and other CI/CD setups, is one example, and its README lists, among the things it finds, “Template injection vulnerabilities, leading to attacker-controlled code execution”, “Accidental credential persistence and leakage” and “Excessive permission scopes and credential grants to runners”. It is MIT-licensed. Static analysis as a GitHub Action, Semgrep for instance, is worth adding once someone will read its findings every week, and not before, as my working rule. Semgrep’s old wrapper action is deprecated, and Semgrep’s docs now run its container image inside a GitHub Actions job.
The “noise level” column below is my reading, not a measured rate.
| Scan | What it catches | Noise level | Run it when |
|---|---|---|---|
| Dependency audit with a high-severity threshold | Published advisories in production dependencies | Medium | Every pull request |
| Secret scan (push protection, or a scanner in CI) | Keys and tokens in a push | Low | Every push |
| Workflow linter (zizmor) | Risky workflow settings, excessive permissions among them | Low | Whenever a workflow file changes |
| Static analysis (Semgrep in a GitHub Action job, for example) | Risky patterns in your own source code | High until tuned | Once someone reads the findings weekly |
Scanner output is not the same as risk. In 3 of the 21 third-party apps I audited, scanners flagged 33 to 44 vulnerabilities and my audit traced exactly zero as reachable. They come from the same June and July 2026 audits of 21 third-party apps, chosen for auditing rather than drawn as a sample of every AI-built app. Tracing reachability is check 11 of the vibe coding security checklist, and security testing in DevOps is only worth its noise when someone triages what it finds, as my working rule. How to set a CI failure policy and keep suppressions honest is a practical scanner sequence for each change.
The opposite failure is no audit at all. In one CRM I audited, thirty-seven of its forty-nine runtime dependencies were pinned to “latest”, with nothing watching for advisories. My reading is that “latest” is the dependency form of a movable action tag, and a dependency audit in CI is what turns the next advisory into a failed build somebody has to read.
Choosing a static analysis tool is its own decision, covered in static code analysis tools. The pull-request checks themselves, lint, types, build and tests, are CI/CD best practices.
The OWASP CI/CD Top 10, mapped to the five controls
The OWASP Top 10 CI/CD Security Risks names 10 risks, from insufficient flow control mechanisms to insufficient logging and visibility. On hosted runners with no artifact registry, my reading is that about half of them apply to a small app, and the five controls on this page cover most of those.
The ids and names below are OWASP’s, in OWASP’s order, from the OWASP Top 10 CI/CD Security Risks. The “applies” and “covered by” columns are my reading for a small app on GitHub-hosted runners.
| OWASP CI/CD risk | Applies to a small app? | Covered by |
|---|---|---|
| CICD-SEC-1: Insufficient Flow Control Mechanisms | Yes | Control 4, the protected branch and the deploy gate |
| CICD-SEC-2: Inadequate Identity and Access Management | Yes | Controls 1 and 3, plus check 7’s token list |
| CICD-SEC-3: Dependency Chain Abuse | Yes | Control 2 for actions, control 5 for packages |
| CICD-SEC-4: Poisoned Pipeline Execution (PPE) | Yes | Control 1, and no pull_request_target on untrusted code |
| CICD-SEC-5: Insufficient PBAC (Pipeline-Based Access Controls) | Partly | Per-job grants (control 1) and environment-scoped secrets (control 3) |
| CICD-SEC-6: Insufficient Credential Hygiene | Yes | Control 3, with checks 4 and 7 |
| CICD-SEC-7: Insecure System Configuration | Partly: the runners are GitHub’s, the settings are yours | Controls 1 and 4, plus the platform settings review |
| CICD-SEC-8: Ungoverned Usage of 3rd Party Services | Partly | The allowed-actions list, and a look at which apps can reach the repository |
| CICD-SEC-9: Improper Artifact Integrity Validation | No, until you publish packages or images | The SLSA section below |
| CICD-SEC-10: Insufficient Logging and Visibility | Partly | Check 4 reads the logs; alerting is beyond these five controls |
In my reading, two of the ten wait on things a small app usually lacks: CICD-SEC-7 grows with self-hosted runners, where the system underneath becomes yours to configure, and CICD-SEC-9 applies once there is an artifact registry or a package others install. A CI/CD security audit at this size is walking this table with the workflow files and the repository settings open, which I’d put at about an hour’s work as my working rule.
The vocabulary: DevSecOps, SLSA, IaC, containers, CSPM
Each term below gets a definition, what applies to a small app today, and nothing more.
DevSecOps tools and security as code: what DevOps security means on a small app
Security as code keeps a pipeline’s security rules in version control, changed by pull request. DevOps security, usually called DevSecOps, runs those checks inside the delivery pipeline. Of the 7 tool categories below, a small app needs two now, dependency audit and secret scanning, plus a protected deploy path, which is a setting.
The philosophy behind DevSecOps fits in one list of its usual principles, written here as my summary rather than a standard: shift checks left, toward the pull request; automate them; share ownership of security with whoever writes the code; keep feedback fast; and keep evidence of what ran. DevOps security is the same idea under its other name. Those core principles of DevSecOps apply to a two-person team as much as to a bank; what changes is how many tools they justify.
In practice, security as code means the rules themselves, workflow permissions, branch rules and scan thresholds, live in the repository and change by pull request like any other code, and GitHub’s CODEOWNERS feature can control how changes are made to the workflow files. The DevSecOps tools on vendor lists fall into seven categories, and the table sorts them by category rather than by vendor. The last column is my working rule for a small app.
| Tool category | What it checks | Does a small app need it yet? |
|---|---|---|
| SCA (dependency audit) | Known vulnerabilities in the packages you install | Yes, on every pull request |
| Secret scanning | Keys and tokens in commits and pushes | Yes, before a push lands |
| SAST (static analysis) | Risky patterns in your own source code | Once someone reads the findings weekly |
| DAST (dynamic testing) | The running app, probed from outside | Before a large launch, not in every build |
| IaC scanning | Terraform and similar files, for risky settings | Only when those files exist |
| Container scanning | Packages inside a built image | Only when you build images |
| Posture management (CSPM) | Settings across a cloud account | Only with a cloud account of your own |
For the gaps each category leaves, see what each scanner type leaves unresolved. The open source DevSecOps tools named on this page cover the SCA, IaC and container rows, so the DevSecOps toolchain for one app can start with no license cost. As my working rule, integrating security into the DevSecOps toolchain at this size means the two scans in CI plus the protected deploy, nothing more. DevSecOps automation, or secure DevOps automation, means those checks run on every pull request so nobody has to remember them, and that is the whole of agile DevSecOps for a small team: the check rides along with each change instead of waiting for a review at the end. The DevSecOps automation tools that do this are ordinary CI steps, not a separate product.
DevSecOps velocity has one practical rule: a slow or noisy check tends to get switched off, so budget the noise before adding a check, as my working rule. For a team that wants a maturity model there is DSOMM, OWASP’s DevSecOps Maturity Model, which in its repository’s words “provides opportunities to harden DevOps strategies and shows how these can be prioritized”. My reading is that it is a map for a security team and not a goal for a two-person team. DevOps compliance at this size means keeping the evidence the pipeline already produces: who approved, what ran and what deployed. How DevOps and DevSecOps differ as words is the DevOps guide’s topic, linked at the top of this page.
SLSA levels, provenance and signed binaries
SLSA, Supply-chain Levels for Software Artifacts, is an OpenSSF framework whose build track rises to a hardened build platform at level 3. It matters to teams that publish packages, images or binaries others install; a SaaS that its host builds from its own repository gains little from it today.
SLSA is pronounced “salsa”, and its site describes it as part of the Open Source Security Foundation. The current version of the SLSA specification is 1.2, and its build track has four levels.
- Build L0, “No guarantees”: no requirements at all.
- Build L1, “Provenance exists”: provenance showing how the package was built.
- Build L2, “Hosted build platform”: signed provenance, generated by a hosted build platform.
- Build L3, “Hardened builds”: a hardened build platform, with the two added controls quoted below.
SLSA provenance is the record of how an artifact was built: in the spec’s words, “what entity built the package, what build process they used, and what the top-level input to the build were.” It “may be incomplete and/or unsigned at L1”, and from L2 the hosted build platform signs it. SLSA level 3 adds the platform controls: the spec requires strong controls to “prevent runs from influencing one another, even within the same project” and to “prevent secret material used to sign the provenance from being accessible to the user-defined build steps.”
On GitHub, GitHub’s artifact attestations create what GitHub calls “unfalsifiable provenance and integrity guarantees” for software built in Actions. GitHub says attestations alone provide SLSA v1.0 Build Level 2, and that combined with reusable workflows they can help reach Build Level 3. The plan condition matters for a private repository: “If you are on a GitHub Free, GitHub Pro, or GitHub Team plan, artifact attestations are only available for public repositories. To use artifact attestations in private or internal repositories, you must be on a GitHub Enterprise Cloud plan.”
Signed binaries and binary signing apply the same idea of proving origin to desktop and mobile apps. Apple’s Gatekeeper protects Mac users by “checking for a Developer ID certificate from apps distributed outside the Mac App Store”. Microsoft’s Smart App Control blocks “unknown, unsigned code” by default, and, when its app intelligence service cannot make a prediction, still allows an app to run if it is signed with a certificate from a certificate authority “within the Trusted Root Program”. My reading is that this matters only if you ship an app to devices. Sigstore describes itself as “an open source project for improving software supply chain security”, and GitHub’s attestations use its public instance for public repositories.
IaC security and IaC scanning tools
IaC security means scanning infrastructure-as-code files, such as Terraform or CloudFormation templates, for security misconfigurations before they are applied. Open-source scanners like Checkov and KICS can run on pull requests. An app with no IaC files has nothing to scan.
Infrastructure as code is the practice of describing servers, networks and permissions in files instead of dashboards; the DevOps guide linked at the top covers it properly. Infrastructure as code scanning reads those files for risky settings before they are applied: Checkov, for example, says it “Detects AWS credentials in EC2 Userdata, Lambda environment variables and Terraform providers.” Three open-source IaC scanning tools come up most, and their repositories describe them this way:
- Checkov, from Bridgecrew’s repository and licensed Apache-2.0, is “a static code analysis tool for infrastructure as code (IaC) and also a software composition analysis (SCA) tool for images and open source packages”.
- KICS, from Checkmarx and licensed Apache-2.0, finds “security vulnerabilities, compliance issues, and infrastructure misconfigurations early in the development cycle of your infrastructure-as-code”.
- tfsec, from Aqua Security and licensed MIT, “is now part of Trivy”: its repository says it remains available for the time being, but engineering attention goes to Trivy.
A managed-platform app has no such files, so these IaC security tools have nothing to read. They start to apply the day someone adds a Terraform folder or a cloud account, and from then on, as my working rule, the scanner runs on every pull request that touches those files.
If you do run containers: container security best practices, Docker settings and hadolint
Container security best practices start in the Dockerfile: a small pinned base image, a non-root user, no secrets in layers or build arguments, and multi-stage builds. My 9-item checklist adds a read-only filesystem, dropped capabilities with no-new-privileges, resource limits, a rebuild whenever the base image updates, and, at runtime, no container given the Docker daemon socket.
Container image security starts in the Dockerfile because that is where the image’s contents are decided. The list below is my container security checklist, drawn from OWASP’s Docker Security Cheat Sheet, Docker’s build best practices and Docker’s security docs, and it covers the Docker security best practices a single app needs.
- 01 A small base image with its version pinned, since Docker says image tags are mutable and a smaller base image minimizes the vulnerabilities its dependencies bring in.
- 02 A non-root user, set with
USERin the Dockerfile. - 03 No secrets in image layers or build arguments: Docker calls build arguments inappropriate for secrets because they persist in the final image.
- 04 Multi-stage builds, so compilers and build tools stay out of the image you ship.
- 05 A read-only root filesystem.
- 06 All capabilities dropped, with only the ones the app needs added back, and
no-new-privilegesset. - 07 Resource limits on memory, CPU and processes.
- 08 A rebuild whenever the base image updates, since an image is a snapshot of the moment it was built.
- 09 The Docker daemon socket never exposed to a container, not even read-only.
The sixth item’s no-new-privileges option, in OWASP’s words, prevents “the container from gaining new privileges via setuid or setgid binaries”. The ninth is OWASP’s rule 1, verbatim: “Do not expose the Docker daemon socket (even to the containers)”. In Docker Compose, the security_opt key is the file form of Docker’s --security-opt flag, and three keys cover items 5 and 6:
services:
app:
read_only: true
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
Written down with a date, that list is a container security policy at this size, and it is what application container security means for one app. My reading is that most security issues with Docker come down to items 2, 3 and 6 missing: a process running as root, a secret baked into a layer, or a container holding capabilities it never uses. Those three Docker security problems need no new tool to fix, only the Dockerfile and the Compose file.
Docker security vulnerabilities sit in the images you build, which the next section scans, and in Docker’s own software: the engine and its components on a server, and Docker Desktop on a developer’s machine. Docker’s security announcements list each Docker Desktop vulnerability with the release that fixed it, beside engine-side advisories such as one for runc, BuildKit and Moby (Docker Engine); the latest Desktop entry on 2026-09-28 reads “A vulnerability in Docker Desktop was fixed on August 10 in the 4.86.0 release”. For a Docker Desktop vulnerability report, my reading is that the fix is updating to the release the page names.
hadolint is an open-source Dockerfile linter, licensed GPL-3.0, which in its README’s words “parses the Dockerfile into an AST and performs rules on top of the AST” and “stands on the shoulders of ShellCheck to lint the Bash code inside RUN instructions”. That makes it the container static analysis step for the Dockerfile, before any image exists.
Hardened minimal base images are a product category of their own. Chainguard describes its images as “minimal, zero-CVE containers”, and RapidFort offers “Curated Near-Zero CVE Container Images” that are continuously patched and hardened. Docker’s own build guidance points the same way: a smaller base image minimizes the vulnerabilities its dependencies bring in, so there are fewer CVEs to track. Choosing between RapidFort and Chainguard, or neither, is a cost decision against the time your team spends on base-image updates, and this page picks no winner.
How do I scan a Docker container for vulnerabilities?
Scanning a Docker container for vulnerabilities means running a scanner such as Trivy or Grype against the image, which compares its packages with vulnerability databases. It finds known CVEs in the base image and dependencies, not flaws in your own code. Run it in CI before push, and let a registry that offers it rescan.
What container scanning does is list the packages inside an image and look each one up in vulnerability databases, so a scan of Docker images for vulnerabilities can only report problems someone has already published. Container vulnerability scanning can run in three places: on a developer’s machine, in CI before the image is pushed, and in the container registry. Some container security scanning tools live in the registry and rescan a Docker image after it is pushed. Amazon ECR’s image scanning docs give one registry’s version: basic scanning offers “Manual and scan on push”, and enhanced scanning offers “Scan on push and continuous scan”, because “Amazon ECR integrates with Amazon Inspector to provide automated, continuous scanning of your repositories.” The ECR page states no cost. Amazon Inspector’s pricing page says accounts new to it “are eligible for a 15-day free trial to evaluate the service and estimate its cost”, so my reading of that pricing page is that enhanced scanning on ECR is paid by usage after the trial.
| Scanner type | Open-source example | What it reads | Where it runs |
|---|---|---|---|
| Image vulnerability scanner | Trivy (Aqua Security’s repository, Apache-2.0) | Container images, filesystems, remote Git repositories and more, for known CVEs, misconfigurations and secrets | A local install or a Docker container; its README lists a GitHub Actions integration |
| Image vulnerability scanner | Grype (sponsored by Anchore, Apache-2.0) | Container images, filesystems and SBOMs, for known vulnerabilities | A local command-line install; CI use not stated in Grype’s README |
| Indexing service | Clair (Quay’s repository, Apache 2.0) | Container images indexed through its API and matched against known vulnerabilities | A service clients call through the Clair API |
| Registry scanning | None: Amazon ECR is a managed example | Images in the registry: OS packages on basic scanning, OS and language packages on enhanced | Inside the registry, on push, manually, or continuously on enhanced |
All three are open-source Docker vulnerability scanners, and each works as a container image scanning tool whether the image is yours or a base image you pull. Grype’s README prints the scan command for an image like this:
# container image
grype alpine:latest
Swap in your own image and tag. A Docker container security scan in CI is that one command run against the image the build just produced, which is the image you ship; the same holds for any other Docker security scanner. A Dockerfile security scan is a different step from the image scan: hadolint reads the Dockerfile before anything is built, while Docker image scanner tools read what the build produced. My working rule for container security testing at this size is one image scan in CI, one Dockerfile lint and, if the registry offers it, a rescan there.
Commercial container security tools exist too, and Snyk’s container scanning is one of them; what one adds over the open-source scanners is not stated in the sources this page used, so check the vendor’s own docs before paying. There is no single best container security tool for a small team, which is why this section sorts them by type rather than rank. Looking up a specific Docker CVE is covered with the npm audit guide linked in the scans section, and Docker Desktop’s own vulnerabilities are in the containers section just above.
CSPM tools and CNAPP: cloud posture, and when a small app needs one
CSPM tools run continuous configuration and security checks across a cloud account and flag risky settings. CNAPP bundles that with DevSecOps and workload protection, containers included, in one product. Both need a cloud account to read, so an app on managed platforms gives them almost nothing to see.
CSPM stands for cloud security posture management, and CNAPP for cloud-native application protection platform. Microsoft’s Defender for Cloud overview defines both at once: Defender for Cloud “is a Cloud Native Application Protection Platform (CNAPP), which is a unified solution that combines multiple cloud security tools to protect applications across their entire lifecycle”, and its CSPM component “checks and improves the security posture of cloud resources.” The other two components it names are DevSecOps, for code-level security, and workload protection for virtual machines, containers, storage, databases and serverless functions. Cloud posture management, in other words, reads the settings of accounts you run.
Both kinds of tool need a cloud account to read. An app on managed platforms has dashboards, not an account API, so my reading is that there is almost nothing for them to see. As my working rule, a posture tool earns its price with several AWS, Azure or GCP accounts, more than one team, or a compliance framework that asks for continuous monitoring.
Before that, the cloud provider’s own CSPM service covers one account. AWS Security Hub CSPM, for example, “automatically runs continuous, account-level configuration and security checks based on AWS best practices and industry standards.” It has a dependency, in AWS’s words: “You must enable AWS Config and record resources in AWS Config for Security Hub CSPM to generate most control findings.” It also has a cost condition: an account enabling it for the first time “is automatically enrolled in a 30-day Security Hub CSPM free trial”, and during that trial “you are charged for usage of other services that Security Hub CSPM interacts with, such as AWS Config items”. CSPM vendors and CNAPP vendors are not listed or ranked here: for a small app, the question to answer before comparing CNAPP tools is whether there is a cloud account to point them at.
How to check your own pipeline
A pipeline is checked with 7 tests: the default token setting, unpinned uses lines, secrets withheld from fork pull requests, log redaction, a refused direct push and a gated deploy, a failing dependency audit, and a token inventory.
Each check is written for a small private repository. Where a setting needs a plan the repository is not on, the evidence to keep is a dated note that the control is missing, as my working rule.
- 01 Token default: open Settings, Actions, General and read "Workflow permissions". Evidence: a screenshot showing read access for the contents and packages permissions.
- 02 Pins: search the workflow files for
uses:lines that end in a tag or a branch instead of a 40-character SHA, then open the "Require actions to be pinned to a full-length commit SHA" setting. Evidence: the search output, empty after the fix, and a screenshot of the setting turned on. - 03 Forks: on a public repository, have a colleague open a pull request from a fork under their own GitHub account (GitHub allows one free account per person), with a step that prints only whether a known secret is empty, such as
[ -z "$SECRET" ] && echo empty, never the value; approve the run if GitHub holds it, since by default all first-time contributors need approval. Evidence: the run log showing it empty. On a private repository, open Settings, Actions, General, Fork pull request workflows and confirm "Send secrets to workflows from pull requests" is off. Evidence: a screenshot. On Vercel, also a screenshot of Git Fork Protection left on in the project Security settings. - 04 Logs: read the logs of recent workflow runs, and the host build logs where the host builds, for any secret value, or a JSON-wrapped or transformed copy of one, that appears in clear. Evidence: the run URLs and the date. If one appears, GitHub says to delete the log and rotate the secret.
- 05 Direct push and deploy gate: with the production branch rule requiring a pull request and set to "Do not allow bypassing the above settings", try to push straight to the production branch. Evidence: the refusal. Then the deploy gate: where Vercel deploys, a screenshot of the Deployment Checks list naming the test job, plus a note of who can use Force Promote; where a workflow deploys, run the deploy job from a branch the environment rule does not allow. Evidence: the refused run. On GitHub Free with a private repository, both branch protection and deployment branch rules are unavailable, so the evidence is the dated note that the control is missing, plus the Deployment Checks screenshot where Vercel deploys.
- 06 Audit threshold: on a branch, add a production dependency (not a dev dependency, which
--omit=devin the audit line skips by design) at a version with a published high-severity advisory, and open a pull request. Evidence: the failed dependency-audit job whose log names that advisory; a job that fails for another reason, such as a missing package-lock, proves nothing. Then delete the branch and, where the host builds every push, the preview deployment it made. - 07 Token inventory: list every token and key the pipeline holds, with its scope and expiry, including the host deploy tokens and who can run a production deploy from the host CLI. Evidence: the list, with a date.
Check 5 sets that option because, by default, a branch protection rule does not apply to people with admin permissions to the repository, and my reading is that on a solo founder’s repository the founder is that admin. The rule itself belongs to the branch protection guide linked in the first section. Check 6’s missing package-lock matters because npm requires a package-lock or shrinkwrap to run the audit by default, which the npm audit guide covers. Keep the screenshots, run URLs and dates together in one folder.
The Production Hardening Sprint verifies two of these controls its own way: for 7.2, the protected production branch, we attempt a failing merge and confirm the protection prevents it, and for 7.3, pull-request CI, we submit failing and passing changes and retain the CI results.
The release checks that follow the pipeline are in the production deployment checklist.
Where the sprint fits
Six deliverables in the Production Hardening Sprint sit next to this page’s topic, and none of them promises the token, pinning or scan settings above. We protect the main branch with required checks and controlled merge permissions (7.2); run linting, type checks, builds and tests on every pull request (7.3); configure Renovate, Dependabot or an equivalent to propose dependency updates with checks (7.9); keep the application in a version-controlled repository and on a hosting account the founder controls, deployable through a documented pipeline, independent of the tool that generated it (7.11); scan Git history for committed secrets and rotate every exposed credential found (2.2); and review and harden the settings of every managed service in the stack: auth policies, storage access rules, privileged keys kept server-side, and a plan tier adequate for backups and recovery (2.9). A control that does not apply to the product is marked with a written reason, and that is never used to move an included item into a paid upgrade. The fee covers our engineering work; hosting, paid tools and API usage remain in your accounts, and we explain any required third-party costs before enabling them. Each deliverable, with how it is verified, is in the published scope.
Common questions about CI/CD and container security
Is Docker still relevant in 2026?
Yes, for anyone packaging software to run on servers they control. In the 2025 Stack Overflow Developer Survey, 71.1% of the 24,473 respondents who answered the question had done extensive development work with Docker in the past year. Many apps on managed platforms never build an image at all, so for them the question does not come up.
When should you not use Docker?
When your host builds and runs your code straight from the repository, a container adds an image you then have to patch and scan, with no security gain in return. That is my reading for an app on Vercel or a similar platform; the calculation changes once you run your own servers.
Is Docker used for security?
Partly. Docker’s docs call namespaces “the first and most straightforward form of isolation”: a process inside one container cannot see, and even less affect, processes in another container or on the host. The same docs note that the daemon needs root privileges unless you opt in to rootless mode, and that only trusted users should control it, so isolation is a starting point rather than a guarantee. What makes a container defensible is the settings, the nine in the containers checklist above.
What are the three pillars of DevSecOps?
The list depends on the author. One named answer is Julien Vehent’s Securing DevOps, published by Manning and excerpted by TechTarget in 2019: test-driven security, monitoring and responding to attacks, and assessing risks and maturing security.
If you have a working app built with these tools and need it ready for real customers, this is what we do.
Built it with AI. Now it has to hold up for real customers.
The Production Hardening Sprint takes the app you already have and builds the production foundation underneath it. Authentication and access rules, payments that stay consistent, error handling, monitoring, backups, automated tests and a documented handover. Our engineers work inside your existing codebase for ten working days. All 123 deliverables are included, and you get the evidence for each one.
See the Production Hardening Sprint →
$2,500 fixed price · 10 working days · One codebase