DevOps ·

Container Security for Startups: Image Scanning, Runtime Policies, and Supply Chain Integrity

A practical guide to container security covering image hardening, vulnerability scanning in CI, runtime policies, supply chain integrity with cosign and SBOMs, and secrets management. Focused on what startups actually need versus enterprise theater.

Container Security for Startups: Image Scanning, Runtime Policies, and Supply Chain Integrity

Most container security guides are written for teams with a dedicated security engineer and a compliance checklist to fill out. Startups do not have that. You have two engineers, a tight deploy window, and a board presentation next week. This article covers what actually matters at that stage: the controls that block real attack paths, the tooling you can wire into CI in an afternoon, and the tradeoffs between security posture and operational overhead.

The Threat Model

Before picking tools, name what you are actually defending against.

The realistic threat surface for a containerized startup application is:

  1. Vulnerable dependencies in the image. A known CVE in a library you pulled six months ago that someone has since weaponized.
  2. Compromised base image. A malicious or tampered layer from Docker Hub or a third-party registry.
  3. Overprivileged containers. A container running as root that can escape to the host on kernel exploit.
  4. Secrets leaked into images. API keys, database credentials, or private keys baked into layers.
  5. Supply chain compromise. A dependency or base image that was legitimate at build time but is now tampered.

Items 1 and 4 cause the most incidents at startup scale. Items 2, 3, and 5 become urgent as you grow. This article works through all five, roughly in order of how fast you should address them.


Base Image Selection and Hardening

Your image inherits every vulnerability in its base. The choice of base image is the highest-leverage decision you make at build time.

Use official, minimal images. ubuntu:latest pulls in 300+ packages, most of which your application never touches. Each package is a potential CVE surface.

Prefer Alpine or Debian slim for development environments. For production, distroless images are the most defensible choice:

# Stage 1: build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build

# Stage 2: runtime (distroless)
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER nonroot
CMD ["dist/server.js"]

Distroless images contain only the language runtime and your application. No shell, no package manager, no curl. If an attacker gets code execution inside a distroless container, their lateral movement options are severely constrained: no shell to drop into, no tools to download payloads with.

The tradeoff: debugging is significantly harder. You cannot kubectl exec into the container and poke around. Consider keeping a debug sidecar available for staging and using structured logs as your primary observability mechanism in production.

Pin image digests, not tags. Tags are mutable. node:20-alpine can be updated by Docker Hub at any time. Pin to the SHA-256 digest:

FROM node:20-alpine@sha256:a1b2c3d4e5f6...

This guarantees you get the exact image you tested against. Check for updates on a schedule (weekly is reasonable) rather than pulling the latest tag implicitly.


Vulnerability Scanning in CI

Scanning catches known CVEs before they reach production. The key word is “known”: scanning gives you no protection against zero-days or vulnerabilities not yet in the database.

Three tools dominate this space:

DimensionTrivyGrypeSnyk
Open sourceYesYesNo (SaaS, free tier)
Scan targetsImages, filesystems, Git repos, IaCImages, filesystems, SBOMsImages, code, IaC, containers
DatabaseAqua Security (Trivy DB)Anchore Grype DBSnyk vuln DB
Zero-day detectionNoNoLimited (proprietary research)
SBOM exportYes (SPDX, CycloneDX)Yes (CycloneDX)Yes
CI integrationGitHub Actions, GitLab CIGitHub Actions, GitLab CIGitHub Actions, native PR comments
False positive rateLow-mediumLowMedium (noisy on transitive deps)
Free tierFully open sourceFully open sourceLimited scans/month
Best forStartups, fast CI setupStartups, SBOM-focused pipelinesTeams wanting managed policy

For most startups, Trivy is the right default. It is fast (under 90 seconds for a typical Node.js image), has a clean exit code contract for CI gates, and requires zero infrastructure to run.

Here is a GitHub Actions workflow that scans on every pull request and gates on HIGH+ severity:

name: Container Security Scan

on:
  pull_request:
    paths:
      - "Dockerfile"
      - "package-lock.json"
      - "src/**"

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build image
        run: docker build -t app:${{ github.sha }} .

      - name: Run Trivy vulnerability scan
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: app:${{ github.sha }}
          format: table
          exit-code: "1"
          severity: HIGH,CRITICAL
          ignore-unfixed: true

      - name: Export SBOM
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: app:${{ github.sha }}
          format: cyclonedx
          output: sbom.json
        if: github.ref == 'refs/heads/main'

      - name: Upload SBOM artifact
        uses: actions/upload-artifact@v4
        with:
          name: sbom-${{ github.sha }}
          path: sbom.json
        if: github.ref == 'refs/heads/main'

Key decisions in this workflow:

  • ignore-unfixed: true reduces noise significantly. A CVE with no available fix is not actionable. You cannot patch it; you can only document it and accept the risk.
  • paths filter avoids re-scanning on documentation or test changes that do not affect the image.
  • SBOM export runs only on main. There is no value in storing SBOMs for every PR commit.
  • Exit code 1 on HIGH/CRITICAL creates a hard gate. This is intentional. A failing scan should block the merge.

On severity thresholds. CRITICAL gates are table stakes. HIGH gates catch a large percentage of exploited vulnerabilities in practice. MEDIUM is where the noise-to-signal ratio degrades quickly; many MEDIUM CVEs have no public exploits and no realistic attack path in your deployment context. Start with HIGH+ and tune from there.


Secrets Management in Containers

The fastest path to a breach is baking secrets into a Docker image. They persist in every layer of the image history, even if you delete them in a later layer.

Never do this:

# This is wrong. The ARG value is in the build history.
ARG DATABASE_URL
ENV DATABASE_URL=$DATABASE_URL

Do this instead: inject secrets at runtime, not build time.

For Kubernetes: use secrets mounted as environment variables or volume files via your secret manager (AWS Secrets Manager, HashiCorp Vault, or Kubernetes Secrets with an external secrets operator).

For simpler deployments: use your platform’s secret management (Railway, Fly.io, Render, and similar all provide first-class secret injection).

If you are running raw Docker on a VM, use environment files that are never committed:

# .env is in .gitignore and never added to the image
docker run --env-file .env --read-only app:latest

Add a .dockerignore file to prevent accidentally copying secrets into the image build context:

.env
.env.*
*.pem
*.key
id_rsa
.aws
.ssh

Audit your existing images. If you suspect a secret was baked in, run:

docker history --no-trunc <image-id> | grep -i "password\|secret\|key\|token"

For a deeper audit, tools like trufflehog and gitleaks can scan image layers for secret patterns.


Runtime Security: Non-Root Containers and Read-Only Filesystems

The principle here is minimizing what an attacker can do if they get code execution inside your container.

Run as non-root. Containers run as root by default. Root inside a container is not root on the host (namespacing provides some isolation), but it dramatically expands the attack surface if there is a container escape vulnerability. The fix is one line in your Dockerfile:

# Create a non-root user
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
USER appuser

Or with distroless (which ships a nonroot user already):

USER nonroot

If you are on Kubernetes, enforce this at the pod spec level:

securityContext:
  runAsNonRoot: true
  runAsUser: 10001
  runAsGroup: 10001

Use read-only root filesystems. If your application does not need to write to the filesystem at runtime (most web servers do not), mount the root filesystem as read-only. This blocks a class of attacks where an attacker writes a backdoor binary to disk.

securityContext:
  readOnlyRootFilesystem: true

If your application legitimately needs writable directories (logs, temp files), mount specific volumes as writable rather than making the entire root filesystem writable:

volumeMounts:
  - name: tmp-dir
    mountPath: /tmp
  - name: log-dir
    mountPath: /var/log/app
volumes:
  - name: tmp-dir
    emptyDir: {}
  - name: log-dir
    emptyDir: {}

Drop capabilities. Linux capabilities give containers a finer-grained privilege model than the root/non-root binary. Most application containers need zero capabilities. Drop all of them, then add back only what you need:

securityContext:
  capabilities:
    drop:
      - ALL
    add:
      - NET_BIND_SERVICE  # Only if binding to ports < 1024

Seccomp and AppArmor Profiles

Seccomp (Secure Computing Mode) restricts which system calls a container can make. AppArmor restricts file system access, network access, and capabilities by process name.

Both are “defense in depth” controls. They do not prevent a vulnerability from being exploited, but they constrain what an attacker can do after exploitation.

Seccomp in Kubernetes. Kubernetes ships a RuntimeDefault seccomp profile that blocks the most dangerous syscalls (kernel module loading, raw socket creation, etc.) with minimal application impact:

securityContext:
  seccompProfile:
    type: RuntimeDefault

Start with RuntimeDefault. It covers 90% of the value with zero configuration. Custom profiles are worth the investment only if you have a specific syscall you need to block that the default does not cover.

AppArmor in Kubernetes. AppArmor profiles are applied via annotations:

metadata:
  annotations:
    container.apparmor.security.beta.kubernetes.io/app: runtime/default

The same principle applies: runtime/default is a good baseline. Custom profiles require profiling your application’s actual syscall and file access patterns, which is a significant time investment.

Practical recommendation for startups. Enable RuntimeDefault seccomp on all production pods. This is a one-line change with almost no operational overhead. Skip custom AppArmor profiles unless you have a specific compliance requirement or a targeted threat model that requires them.


Supply Chain Integrity: Image Signing and SBOMs

In December 2020, the SolarWinds attack demonstrated what supply chain compromise looks like at scale. In the container world, the equivalent is a tampered base image or a compromised CI pipeline that produces a malicious image that looks legitimate.

Image signing solves the verification problem: you can cryptographically prove that an image was built by a specific pipeline and has not been tampered with since.

cosign and Sigstore. Sigstore is the open standard; cosign is the CLI tool. Together they let you sign images using keyless signing (backed by OIDC identity providers) or a long-lived key pair.

Install cosign, then sign an image after pushing:

# Keyless signing using the GitHub Actions OIDC token
# No key management required
cosign sign --yes ghcr.io/your-org/app:${{ github.sha }}

In your Kubernetes admission webhook or policy engine (OPA/Gatekeeper, Kyverno), verify signatures before allowing images to run:

# Kyverno policy: require cosign signature
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-signed-images
spec:
  validationFailureAction: Enforce
  rules:
    - name: verify-image-signature
      match:
        any:
          - resources:
              kinds:
                - Pod
      verifyImages:
        - imageReferences:
            - "ghcr.io/your-org/*"
          attestors:
            - entries:
                - keyless:
                    subject: "https://github.com/your-org/your-repo/.github/workflows/*.yml@refs/heads/main"
                    issuer: "https://token.actions.githubusercontent.com"

This policy rejects any pod that tries to run an image from your registry that was not signed by your main branch CI pipeline. Tampered images fail the signature check and never run.

SBOMs (Software Bill of Materials). An SBOM is a machine-readable list of every component in your image: packages, libraries, versions, licenses. It has two uses:

  1. Incident response. When a new CVE drops (Log4Shell being the canonical example), you can query your SBOM inventory to find every image that contains the affected version, rather than manually auditing all of your repos.
  2. Compliance. The US Executive Order on Improving the Nation’s Cybersecurity (EO 14028) mandates SBOMs for federal software suppliers. If you ever sell to government or large enterprise, you will need them.

Generate SBOMs with Trivy (already in the CI workflow above) or Syft:

# Generate CycloneDX SBOM for an image
syft ghcr.io/your-org/app:latest -o cyclonedx-json > sbom.json

# Attach the SBOM to the image as an attestation (queryable via cosign)
cosign attest --yes --predicate sbom.json --type cyclonedx ghcr.io/your-org/app:latest

Attaching the SBOM as a cosign attestation means you have a signed, tamper-evident record of what went into each image, stored alongside the image in your registry.


What Startups Actually Need vs. Enterprise Theater

Here is an honest breakdown of which controls provide real value at startup scale versus which ones are compliance theater that consumes engineering time without meaningfully improving security posture:

ControlStartup valueWhen it actually matters
Trivy scanning in CI (HIGH+ gate)HighDay one. Most incidents involve known CVEs.
Non-root containersHighDay one. Zero operational overhead.
Read-only root filesystemHighDay one if your app supports it.
.dockerignore and no baked secretsCriticalBefore any internet-facing deploy.
Distroless base imagesMedium-highWhen you have multi-stage builds working cleanly.
Pinned image digestsMediumOnce you have automated update tooling (Dependabot, Renovate).
RuntimeDefault seccompMediumOne-line change; just do it.
cosign image signingMediumWhen you have more than one engineer merging to main.
SBOM generationLow-mediumValuable for incident response; critical for compliance.
Custom seccomp profilesLowOnly if you have a specific threat requiring it.
AppArmor custom profilesLowCompliance requirement or specialized threat model only.
Admission webhooks (Kyverno, OPA)Low-mediumWhen you have multiple clusters or multiple teams deploying.

The first five rows in the table are the minimum viable container security posture. They take an afternoon to implement and cover the attack paths that actually occur at startup scale. Everything below that is a valid investment as you grow, but it competes with product work and should be triaged accordingly.


Production Considerations

Registry security. Use a private registry (GHCR, ECR, GCR, or Artifact Registry). Enable image vulnerability scanning at the registry level as a second scan pass. Require authentication for all pulls; disable public image access by default.

Keep base images updated. Scanning is worthless if you never act on results. Automate base image updates with Renovate or Dependabot. Configure these tools to open PRs when new base image digests are available, letting your CI scan pipeline validate the update automatically.

Layer order matters. Dependencies change less frequently than application code. Order your Dockerfile to maximize layer cache hits and minimize what changes on each build:

# Good: dependencies layer is cached unless package.json changes
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .

Image signing requires OIDC trust. Keyless cosign signing via GitHub Actions works out of the box with GHCR. If you are pushing to ECR or Artifact Registry, verify that your OIDC configuration is correct before relying on signature verification in production.

Scan for secrets in CI independently. Trivy does not scan for secrets in code by default. Add a dedicated secret scanning step using trufflehog or gitleaks to catch credentials before they reach the image build:

- name: Scan for secrets
  uses: trufflesecurity/trufflehog@main
  with:
    path: ./
    base: ${{ github.event.repository.default_branch }}
    head: HEAD
    extra_args: --only-verified

The non-obvious insight about container security is that 80% of the value comes from a small number of controls that are easy to implement: non-root users, read-only filesystems, vulnerability scanning with a hard gate, and keeping secrets out of images. These are not glamorous, but they block the actual attack paths. The more sophisticated controls (image signing, custom seccomp profiles, admission webhooks) matter as your attack surface grows, your team grows, and your compliance requirements expand. Implement them in that order.

More in DevOps

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code
DevOps ·

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code

A deep dive into Terraform's internal architecture covering HCL parsing, the expression evaluation engine, DAG-based dependency resolution, the gRPC provider plugin protocol, state mechanics, and the plan/apply lifecycle.

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container
DevOps ·

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container

A deep dive into what happens when you run docker run: Linux namespaces for process isolation, cgroups v2 for resource enforcement, overlay2 union filesystem for layered images, the OCI runtime spec and how runc spawns containers, content-addressable storage, the containerd shim architecture, and networking with veth pairs.

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop
DevOps ·

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop

A deep dive into Kubernetes internals covering the control plane architecture, etcd watch mechanics, the scheduler's filter and score phases, controller manager reconciliation loops, kubelet pod assignment, the CRI and CNI plugin models, and production considerations for resource requests, pod disruption budgets, and cluster autoscaler behavior.

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution
DevOps ·

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution

A deep dive into Docker internals covering Linux namespace isolation, cgroup resource constraints, OverlayFS copy-on-write layer mechanics, Dockerfile build cache invalidation rules, the containerd and runc OCI runtime stack, and production considerations for image hardening and startup latency.