IaC and containers
Infrastructure as code (IaC) describes cloud resources, networks and permissions in files such as Terraform; Dockerfiles and Kubernetes manifests describe how the software is packaged and run. This practice reviews and tests those files like code, and makes the platform refuse configurations that break the rules.
One line in a Terraform plan can open a database to 0.0.0.0/0 or let a staging pipeline assume a production role, and an image can carry a registry token in its build history or run as root. Application tests do not look at these files. Policy checks in CI catch the change before it is applied, and admission control catches what skips CI, such as a kubectl apply from a laptop.
- Who
- Platform and DevOps engineers who write the Terraform, Dockerfiles and manifests, with AppSec owning the shared policies
- When
- On every pull request that changes infrastructure, images or manifests, and again when the cluster admits each workload
- How often
- Every change in CI; base images rebuilt on a schedule; drift checked on a schedule; policies reviewed on platform upgrades
- You end up with
- Policy-as-code rules that gate CI, hardened image and pod baselines, and admission policies the cluster enforces
Do this
Review the plan, not only the diff
Run
terraform show -jsonon the exact plan you will apply and review it as four questions: who can obtain each identity, what each identity can do, what becomes reachable from the network, and where secrets end up. Treat trust and permission as separate findings: a narrow permission policy does not help if the wrong workload can assume the role. Then apply the plan file that was reviewed, not a fresh one.Turn recurring rules into policy as code
Write the rules every change must meet, such as no wildcard IAM actions, nothing but ports 80 and 443 open to the internet and no privileged pods, as policies that run in CI and fail the pipeline on a violation. conftest runs OPA Rego policies against Terraform plans, Dockerfiles and Kubernetes YAML; scanners such as Checkov and Trivy ship ready-made checks. Review the judgment calls by hand.
Build minimal images that run as non-root
Use a multi-stage build that ships a minimal runtime stage without a shell, package manager or compiler, pin base images by digest (
name:tag@sha256:...) and let a bot refresh the digest, and setUSERto a numeric non-root UID. Pass build credentials only through BuildKit secret mounts (RUN --mount=type=secret):ARGandENVvalues stay in the image history. Scan the pushed digest in CI.Run every pod with the fewest privileges
Set
runAsNonRoot: true,allowPrivilegeEscalation: false,capabilities.drop: ["ALL"]andseccompProfile.type: RuntimeDefault, which the Restricted Pod Security Standard requires, and addreadOnlyRootFilesystem: truewith a size-limitedemptyDirfor scratch files. Give each workload its own service account bound to a Role with only the resources and verbs it uses.Enforce the rules at admission
Label each application namespace
pod-security.kubernetes.io/enforce: restrictedwithenforce-versionpinned, after a--dry-run=serverof the label change lists the pods that would fail. For rules Pod Security cannot express, such as allowed registries or signed images, use a ValidatingAdmissionPolicy (built in, written in CEL, stable since Kubernetes 1.30) or an engine such as Kyverno or OPA Gatekeeper.Keep secrets out of state and images
Terraform writes secret values into plan and state files even when a variable is marked
sensitive, so use ephemeral values (Terraform 1.10 or later) and write-only arguments (1.11 or later) where providers support them, and store state encrypted with few readers. Allowlist the Docker build context with.dockerignoreso.env,.gitand.npmrcnever reach a layer.Check what was actually deployed
After apply, test that the intended identity can do its job and an unrelated one cannot. Check the service account with
kubectl auth can-i <verb> <resource> --as=system:serviceaccount:<ns>:<sa>, and the pod settings from inside the container (id,CapEffin/proc/self/status). Runterraform plan -refresh-only -detailed-exitcodeon a schedule: exit code 2 means live infrastructure has drifted from state.
Done when
Policy checks run on IaC and manifests in CI, and the cluster rejects workloads that break them.
Mark it in the gap checkCommon mistakes
- Running policies only in CI. A
terraform applyorkubectl applyfrom a laptop skips them; admission control and drift detection catch what the pipeline never saw. - Marking a Terraform variable
sensitiveand treating the secret as protected. It is hidden in terminal output but still readable in the plan and state files. - Deleting a secret in a later Dockerfile step. It remains in the earlier layer and in the build history, readable by anyone who can pull the image.
- Setting
USERto a name instead of a number. The kubelet cannot verifyrunAsNonRootagainst a user name and refuses to start the container. - Fixing a startup error by going back to root or a writable root filesystem, instead of finding the one path the process needs to write and mounting a volume there.
- Enforcing pod rules only on application containers in a custom policy. Pod Security checks init and ephemeral containers too; a custom policy that does not match them lets
kubectl debugattach a container the policy would reject.
Pod Security Standards: what each level blocks
Kubernetes defines three levels, and the built-in Pod Security Admission controller enforces one per namespace. Restricted is a common target for application namespaces; Baseline suits workloads that cannot meet it yet.
| Level | Kubernetes description | Main controls |
|---|---|---|
| Privileged | Unrestricted; allows known privilege escalations | None; reserve it for system workloads you fully trust |
| Baseline | Prevents known privilege escalations; allows the default pod configuration | No privileged containers, host namespaces, hostPath volumes or host ports; only a short list of added capabilities; seccomp not Unconfined |
| Restricted | Follows current pod hardening best practices | Baseline plus runAsNonRoot, allowPrivilegeEscalation: false, drop ALL capabilities (add back only NET_BIND_SERVICE), seccomp RuntimeDefault or Localhost, and a short list of volume types |
| Your own policy | Not part of any level | readOnlyRootFilesystem: true, resource limits, image digests and signatures, allowed registries |
Tools and guides
- Browser labs
- Review infrastructure changes as access changes
Inspect the effective permissions, exposure, sensitive state, and recovery implications of a proposed infrastructure change.
- Harden container images from Dockerfile to admission
Build minimal, non-root images pinned by digest, keep secrets out of every layer, then scan, sign and verify the exact digest before it runs with dropped privileges.
- Run Kubernetes workloads with only the privileges they need
Review pod settings, service accounts and effective permissions together before treating a namespace as an isolation boundary.
- Test infrastructure policy and detect security-relevant drift
Connect source configuration, the proposed plan and deployed resources so a safe review does not become stale after deployment.
Standards
- NIST SSDF 1.1
- PW.9 Configure Software to Have Secure Settings by Default
- OWASP SAMM 2
- Secure DeploymentEnvironment Management
Sources
- Kubernetes Pod Security Standards. Kubernetes v1.37.
- Kubernetes Pod Security Admission. Kubernetes v1.37.
- Kubernetes ValidatingAdmissionPolicy. Kubernetes v1.37.
- OWASP Docker Security Cheat Sheet
- Docker: building best practices
- Docker: build secrets
- conftest documentation
- HashiCorp: managing sensitive data in Terraform
- Terraform: plan -refresh-only and detailed exit codes
- NIST SP 800-218: Secure Software Development Framework (SSDF) Version 1.1, practice PW.9. February 2022.
- OWASP SAMM: Secure Deployment