Building a Signed Software Supply Chain for a One-Person SaaS
Every commit to my SaaS is now signed by a short-lived certificate, every release is signed by the CI workflow's own identity, and production refuses to run anything it cannot verify. Here is how I built it on a small budget, and what it found when I pointed security scanners at my own code.
Why build this
Stokum.NET is a multi-tenant inventory platform I develop alone. A one-person team has the same supply-chain risks as a large one: a stolen laptop, a leaked token, a poisoned dependency or a compromised CI runner can each put malicious code in front of customers. What a solo developer lacks is the second reviewer, the security team and the budget.
So I set five goals:
- Integrity: know exactly what runs in production, who wrote it and who built it, from commit to deploy.
- Shift left: find vulnerabilities in the pull request, not in production.
- Containment: a compromise of one part, such as the CI runner, must not reach the signing services or production.
- Auditability: every security-relevant event lands in one central log, with alerts.
- Enterprise controls on a small budget: run the same controls a large AppSec program would, on self-hosted open-source tools.
Before adding any scanner, I audited my own infrastructure. That story is in Configuration isnβt proof: five findings from auditing my own infrastructure.
Architecture
Four physical machines carry the platform: a developer laptop, a small cloud server at the edge, a home hypervisor split into isolated zones, and a production host. Everything talks over a private WireGuard network; only two names are public.

Machine | Role | What runs there |
|---|---|---|
Developer laptop | Development | The only machine that writes code; signs commits and tags; no Node.js or npm, by decision |
Edge server (small cloud VM) | Edge | TLS reverse proxy, WireGuard hub, DNS with DNSSEC, mail, an independent transparency-log witness |
Home hypervisor | Platform | Incus VMs in separate zones: trust (Sigstore), source (Forgejo), build (CI runner), egress (proxy), logs (Loki/Grafana); drop-by-default rules between zones |
Production host | Production | The application as signed containers pulled by digest; secrets from a secrets manager through one machine identity |
The zones matter more than the boxes: the build zone can push images and request signatures through the front door, but it cannot reach the signing services' internals, the source zone's admin side or production.
From commit to production
Trust is tied to identities and a public log, never to a signing key someone could copy. The numbers match the diagram.
- Commit (1β3).
gitsignopens a browser login through the OIDC provider. Fulcio issues a certificate valid for about ten minutes, bound to my email, and the commit is signed with a key that exists only for that moment. The signature goes into Rekor, an append-only transparency log, and the timestamp authority proves when it was made. There is no long-lived signing key on the laptop to steal. - Pull request (4β6).
mainis protected: nothing merges without a pull request, and five checks must pass: signature verification of every commit, build and tests, secrets scanning, SAST and SCA. Administrators are not exempt. - Release (7β8). A signed tag, created only after a guard confirms the merged commit is on
main, starts the release workflow. CI builds the container image and signs it keylessly with the workflow's own OIDC identity, not mine, attaches an SPDX SBOM as a signed attestation, and pushes it to the registry. - Deploy (9β10). The production host pulls by digest only and runs
cosign verifyagainst the exact identityrelease.yml@refs/tags/vX.Y.Zplusverify-attestationfor the SBOM. Deployment configuration is itself a signed release of an infrastructure repository; the deploy script compares the new configuration with the running one and proceeds only if the difference is exactly the expected image line. Rollback is one command.
A trust root ties it together: the root CA lives offline on two encrypted USB drives, and a TUF repository distributes the trust root (which CA, log and timestamp authority to trust) so it can be rotated safely. A witness on a separate server checks that the transparency log is never rewritten.
The tools and what each one does
Everything is open source and self-hosted.
Area | Tool | What it does here |
|---|---|---|
Source and CI | Forgejo | Git hosting, pull requests, branch protection, required checks, container registry |
Source and CI | Forgejo Actions runner | Runs CI jobs in containers on the isolated build VM |
Signing | Fulcio | Code-signing certificate authority: short-lived certificates bound to an OIDC identity (keyless signing) |
Signing | Dex | OIDC identity provider for people who sign |
Signing | Rekor (with Trillian) | Transparency log: an append-only Merkle tree recording every signature, so misuse is visible |
Signing | Timestamp authority | RFC 3161 timestamps proving when something was signed |
Signing | TUF | Secure distribution and rotation of the trust root; its root key stays offline |
Signing | gitsign, cosign | Sign and verify commits and tags (gitsign), container images and attestations (cosign) |
Signing | Syft (SPDX) | Generates the SBOM attached to each image |
CI scanning | Gitleaks | Secrets in new commits; blocking |
CI scanning | Semgrep | SAST for Go, JavaScript and TypeScript, with rules pinned to a commit |
CI scanning | gosec | Go-specific SAST |
CI scanning | OSV-Scanner | SCA: known vulnerabilities in dependencies |
CI scanning | govulncheck | Go SCA with reachability: flags only vulnerable code the program actually calls |
CI scanning | SARIF | Standard findings format, ready for a findings platform later |
Infrastructure | Incus | VMs and network zones with drop-by-default rules |
Infrastructure | WireGuard | Private network; administration only through it |
Infrastructure | Squid | Egress proxy with a login per VM; every outbound request is logged |
Infrastructure | Infisical | Secrets manager; production uses one machine identity |
Monitoring | Loki, Grafana, Alloy | Central audit log, dashboards and email alerts: SSH logins off the allowlist, sudo failures, a machine going silent, a failed log-witness check, a crashed system manager |
Edge | nginx, BIND, Postfix | TLS termination, DNSSEC-signed DNS, mail with DMARC and MTA-STS |
Adding AppSec gates
With the signed pipeline in place, I added a security workflow to both repositories: secrets scanning (blocking from day one), SAST and SCA (advisory until the backlog was triaged, then required). Every tool is pinned by SHA-256 or the Go checksum database, and reports are written as SARIF.
Repository | Scanner | First run | After triage |
|---|---|---|---|
Backend (Go) | govulncheck (reachable only) | 32 | 0 |
Backend (Go) | OSV-Scanner | 4 | 0 |
Frontend (Next.js) | Semgrep | 130 | 0 |
Frontend (Next.js) | OSV-Scanner | 139 advisories (5 Critical) in 26 packages | 0, plus one time-boxed dev-only exception |
Backend. All 32 reachable findings were in the Go standard library, so a toolchain upgrade cleared them. Code review of the SAST findings closed an ORDER BY injection risk: sort columns now pass an allowlist at the database layer, with unit tests. Session cookies are always Secure, and raw database errors no longer reach clients.
Frontend. About 115 of the 130 Semgrep findings were noise: translation-style rules and an audit rule that flags every React useRef. I limited the rule set to the security and correctness folders and wrote the reason into the workflow, while the rules for real sinks (innerHTML, dangerouslySetInnerHTML, eval, postMessage) stay active. The findings that remained were real: prototype pollution and a regular expression built from keys in API data, inside the translation helper. Lookups now accept own properties only, and one fixed {{name}} pattern replaced the dynamic regex. I also removed a test page that had shipped to production and an inline script that would have blocked a strict Content Security Policy.
On the dependency side, the biggest win was a deletion: a secrets-management SDK was listed as a frontend dependency but imported nowhere. Removing it took out about 85 advisories, including the AWS SDK, axios and an XML parser with a Critical. Upgrading Next.js fixed its own Criticals. The remaining dev-tooling advisories were fixed with npm update --before, which enforces my 14-day release cooldown at the package-manager level.
The triage rule I follow: fix it, suppress it on that line with a written reason, or accept it with an expiry date. Never a global ignore. The one open advisory sits in osv-scanner.toml with an ignoreUntil date; if nobody fixes it in time, the gate turns red again by itself.
Design decisions and trade-offs
Decision | Why | Cost I accepted |
|---|---|---|
Self-host Sigstore instead of the public instance | Private code and identities stay private; I control the trust root and the log, and I learned how each part works | Running a CA, a log and a timestamp authority myself, including an offline root ceremony |
Keyless signing everywhere | No long-lived signing key to leak; identity, not key possession, is what production checks | Signing needs the identity provider to be up |
Verify at deploy, not only in CI | CI results can be forged by a compromised runner; production checks the signature itself | One more step in every deploy |
14-day cooldown on new releases | Most malicious package versions are caught and pulled within days | Waiting for fixes, so security patches get a documented waiver |
Advisory first, then required | Turning on a red gate with nearly 270 frontend findings teaches people to ignore it | A short window where findings did not block merges |
Network zones with drop-by-default | Containment: one compromised VM should not reach the others | Every new path needs an explicit rule |
Exceptions with expiry dates | Accepted risk stays visible and comes back on its own | A little paperwork per exception |
Known gaps I accept for now, and track: all zones share one physical host, signing keys are file-based until an HSM arrives, and there is no second human reviewer, so automated checks and signatures carry more weight.
Lessons learned
- Clocks are part of your trust model. CI signatures sometimes failed verification because the certificate's start time was "now" and a verifier with a slightly different clock saw it as not yet valid. Back-dating the start by 30 seconds in Fulcio, built from source, fixed it.
- Triage beats volume. 130 SAST findings looked alarming; about 15 mattered. A noisy gate gets ignored, a trusted one gets used.
- Your dependency list is attack surface. The cheapest vulnerability fix was deleting a package nobody used.
- Guards beat good intentions. One release tag was once created before its pull request merged, so it pointed at the old code. Now a script refuses to tag unless the merged commit is on
main, and refuses to deploy unless the change is exactly the expected line. - A platform includes its host. The hypervisor once froze when an automatic package refresh triggered a bug in its system manager. Recovery, a root cause and a new alert followed, and the host now runs nothing it does not need.
- AI as a pair engineer, under the same rules. I used an AI assistant to draft scripts and review changes. Every change still went through a signed pull request and the same gates. That is the posture I want for AI in a security pipeline: helpful, never trusted to merge or sign.
What's next
The signed pipeline (milestone 1) and the first AppSec slice are done. The rest of milestone 2, in order:
- STRIDE threat model of the application: short-lived JWTs and refresh-token rotation, tenant isolation by subdomain, CORS, CSP, rate limiting and abuse cases.
- DAST with OWASP ZAP, including an API scan, against a staging copy.
- One view of all findings in DefectDojo, fed by the SARIF reports.
- Guarded AI triage: a local model drafts triage notes, read-only and advisory, unable to merge or sign.
- SLSA provenance as signed attestations, aiming at SLSA Build Level 3.
After that: Kubernetes with an admission policy that rejects unsigned images, infrastructure as code with nightly drift detection, and signing keys in a hardware security module.
If you are building something similar, start with one rule: production should refuse what it cannot verify. Everything else in this post grows from it.
Member discussion