8 min read

Building a Signed Software Supply Chain for a One-Person SaaS

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:

  1. Integrity: know exactly what runs in production, who wrote it and who built it, from commit to deploy.
  2. Shift left: find vulnerabilities in the pull request, not in production.
  3. Containment: a compromise of one part, such as the CI runner, must not reach the signing services or production.
  4. Auditability: every security-relevant event lands in one central log, with alerts.
  5. 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.

Commit-to-production flow and trust zones (anonymized)

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.

  1. Commit (1–3). gitsign opens 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.
  2. Pull request (4–6). main is 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.
  3. 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.
  4. Deploy (9–10). The production host pulls by digest only and runs cosign verify against the exact identity release.yml@refs/tags/vX.Y.Z plus verify-attestation for 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

  1. 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.
  2. Triage beats volume. 130 SAST findings looked alarming; about 15 mattered. A noisy gate gets ignored, a trusted one gets used.
  3. Your dependency list is attack surface. The cheapest vulnerability fix was deleting a package nobody used.
  4. 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.
  5. 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.
  6. 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:

  1. STRIDE threat model of the application: short-lived JWTs and refresh-token rotation, tenant isolation by subdomain, CORS, CSP, rate limiting and abuse cases.
  2. DAST with OWASP ZAP, including an API scan, against a staging copy.
  3. One view of all findings in DefectDojo, fed by the SARIF reports.
  4. Guarded AI triage: a local model drafts triage notes, read-only and advisory, unable to merge or sign.
  5. 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.