Cloud Security Part 5 of 5 · Cloud Security Foundations
Production Cloud Hardening: A Checklist That Connects the Controls
The hardening checklist for a cloud-hosted production workload, built from parts 1 to 4 and the existing edge, container and Kubernetes articles: each control with the command that proves it and the gap it covers.
On this page
Overview
A hardening checklist is usually a list of settings. This one is a list of questions, each with the command that answers it, because a setting that was applied once and never checked again is a belief. Every verification below was run while writing this path: against the kind cluster from Parts 1 to 4, against the images from Part 3, or read-only against this site’s production edge. The cloud-provider items that could not be exercised without an account are marked as such.
The list is organised by the layer that fails, not by the tool that fixes it, and each section ends with the gap that layer leaves and the section that covers it. That is the real content: controls are chosen so that each one’s blind spot is another one’s job.
- Identity decides who can call the API at all (Part 1). Its gap: a socket that does not check identity.
- Exposure decides which sockets are reachable and from where (Part 2). Its gap: the one port that must be public.
- Transport and headers make the public port safe to serve: TLS, HSTS, a CSP. Their gap: what the code behind the port does with a secret.
- Secrets and images keep credentials out of the artifact and the storage layer (Part 3). Their gap: a package with a known vulnerability, and a control that stopped working.
- Visibility records every decision the other layers made (Part 4). Its gap: the moment the data is gone.
- Backups and recovery are the layer that exists for when the others fail. Their gap: they are a copy of everything, including the secrets.
1. Identity
| Question | Check | From |
|---|---|---|
| Does any workload or pipeline hold a static key? | Inventory of IAM users with access keys; kubectl get secrets -A --field-selector type=kubernetes.io/service-account-token | Part 1 |
| Can the CI identity do more than its stage needs? | Checkov on the role policies: CKV_AWS_62, CKV_AWS_286 to CKV_AWS_290 | Part 1 |
| Does a new identity start with nothing? | kubectl auth can-i --list --as=system:serviceaccount:<ns>:<sa> | Part 1, RBAC lab |
| Is there a break-glass account, and is its use alerted? | The identity provider’s log for the account; an alert that fired in a drill | Part 1 |
Run on the kind cluster, the legacy token Secret from Part 1 shows up in the first check, which is the point of
the check; delete it. The can-i --list for the api account returned only the self-review resources
(selfsubjectreviews, selfsubjectaccessreviews, selfsubjectrulesreviews) and the discovery URLs every
authenticated identity has.
The gap: identity says nothing about a listener that never asks. Section 2.
2. Exposure
| Question | Check | From |
|---|---|---|
| What listens on all interfaces? | lsof -nP -iTCP -sTCP:LISTEN on a host; docker ps --format '{{.Ports}}' | Part 2 |
Which inbound rules name 0.0.0.0/0, and why each? | Checkov CKV_AWS_24 (SSH), CKV_AWS_260 (HTTP); a grep of the Terraform for the CIDR | Part 2 |
| Can the data tier reach the internet? | A connection attempt from inside it: nc -zv -w 3 example.com 443 | Part 2 |
| Can tier A reach tier C directly? | A connection attempt by IP from the wrong side, on the real platform | Part 2 |
| Is the management interface public? | dig +short api.<cluster>; the provider’s authorised-networks setting | Part 2 |
The fourth line is the one Part 2 failed on its own laptop: the internal network was reachable by IP. The check is a connection attempt, not a review of the diagram.
The gap: one port has to be public. Section 3.
3. Transport and headers
The public port on this site is Cloudflare’s edge, and its configuration is documented in Hardening a Static Site on Cloudflare. The checks below were run against production on 2026-09-16 and are the ones to repeat after any edge or origin change:
curl -sI http://sachinchaurasiya.com/ | grep -iE '^(HTTP|location)'
curl -sI https://www.sachinchaurasiya.com/ | grep -iE '^(HTTP|location)'
curl -sI https://sachinchaurasiya.com/ | grep -iE '^(strict-transport|content-security|x-content-type|referrer|permissions|x-frame|cross-origin)'
curl -s -o /dev/null -w '%{http_code}\n' https://sachinchaurasiya.com/does-not-exist
HTTP/1.1 301 Moved Permanently
Location: https://sachinchaurasiya.com/
HTTP/2 301
location: https://sachinchaurasiya.com/
strict-transport-security: max-age=31536000; includeSubDomains
content-security-policy: frame-ancestors 'none'
cross-origin-opener-policy: same-origin
permissions-policy: accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), interest-cohort=()
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
x-frame-options: DENY
404
Plain HTTP and the www host both redirect to the one canonical origin, the hardening headers are present on
every response, and an unknown path is a real 404, not a 200 with an error page. The TLS side from Part 2:
TLS 1.3 negotiated by default, TLS 1.2 available, TLS 1.1 refused by the server with a protocol-version alert,
certificate valid until Dec 13 2026, issued by Google Trust Services through Cloudflare. DNSSEC is on: the
zone publishes a DS record (2371 13 2 …).
| Question | Check |
|---|---|
| Is anything served over plain HTTP? | curl -sI http://<host>/ returns 301 to https:// |
| Is old TLS refused, not merely not preferred? | openssl s_client -tls1_1 -cipher 'DEFAULT:@SECLEVEL=0' gets an alert |
| When does the certificate expire, who renews? | openssl x509 -noout -dates; the TLSCertificateExpiringSoon rule from Part 4 |
| Are the hardening headers on every response? | The curl -sI above; pnpm check:headers in this repository fails the build without them |
| Does the CSP allow inline script? | grep -o "script-src[^;]*" on the page contains hashes, not 'unsafe-inline' |
The gap: none of this constrains what the application does with a credential. Section 4.
4. Secrets, images and patching
| Question | Check | From |
|---|---|---|
| Is a credential in the image? | docker history --no-trunc; trivy image --scanners secret; the deleted-layer extraction | Part 3 |
| Are Secrets encrypted at rest? | etcdctl get /registry/secrets/<ns>/<name> shows k8s:enc:, not the value | Part 3 |
| Do consumers pick up a rotation? | Rotate a test Secret; read the file and the env var from the pod | Part 3 |
| Are fixable HIGH and CRITICAL findings blocked? | trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 | Container Security |
| Is the image what the pipeline built? | cosign verify by digest before deploy | Supply Chain path |
| Is the base image current? | The Grype count on rebuild; the Node image tag against upstream | Supply Chain, part 3 |
| Does infrastructure change pass a scan before apply? | Checkov in the merge request with a baseline | IaC Security path |
Patching is a schedule, not a task: a rebuild of every image on a cadence (weekly is common) whether or not the application changed, because the base image and the transitive dependencies change without you. The Grype article on this site shows what one rebuild does to a finding count; the number it produces on your image is the metric to trend.
The gap: a control that was disabled, a permission that was widened, a scan that was skipped. Section 5.
5. Visibility
| Question | Check | From |
|---|---|---|
| Is API audit logging on, and does the policy skip Secret bodies? | The --audit-policy-file flag; the first rule is Metadata for secrets | Part 4 |
| Do the logs leave the node? | An event from an hour ago is queryable somewhere the node cannot delete it | Part 4 |
| Does each control in Sections 1 to 4 produce an event when it acts? | The 403, the dropped flow, the refused write, each found in its log after a test | Part 4 |
| Is anyone paged for privileged changes? | A create clusterrolebinding in a test window produced an alert | Part 4 |
| Are the alert rules loadable? | promtool check rules alerts.yaml on every change | Part 4 |
The third line is the one that ties the path together. Each earlier part ended with an action that should have left a record; Part 4 found them. The test to repeat is the same: do the thing, then find it in the log.
The gap: logs describe the past. Section 6.
6. Backups and recovery
Backups are the control for the failure of every other control, and they are also a copy of the data those controls protect. On the kind cluster:
kubectl -n kube-system exec etcd-cloud-lab-control-plane -- etcdctl \
--cacert /etc/kubernetes/pki/etcd/ca.crt --cert /etc/kubernetes/pki/etcd/server.crt --key /etc/kubernetes/pki/etcd/server.key \
snapshot save /var/lib/etcd/backup-2026-09-16.db
kubectl -n kube-system exec etcd-cloud-lab-control-plane -- etcdutl snapshot status /var/lib/etcd/backup-2026-09-16.db --write-out=table
Snapshot saved at /var/lib/etcd/backup-2026-09-16.db
+---------+----------+------------+------------+---------+
| HASH | REVISION | TOTAL KEYS | TOTAL SIZE | VERSION |
+---------+----------+------------+------------+---------+
| f05ebad | 2261 | 395 | 2.0 MB | 3.6.0 |
+---------+----------+------------+------------+---------+
Copied off the node and searched:
strings etcd-snapshot.db | grep -c 'rotated-password'
1
That snapshot was taken before Part 3 enabled encryption at rest; the Secret’s value is in it in plain text,
and would be in every backup taken since the cluster was created. The surprise came after encryption was
enabled and every Secret rewritten: a fresh snapshot still contained rotated-password once. etcd keeps
previous revisions of every key until they are compacted, so the plaintext version written before the rewrite
was still in the store’s history, and a snapshot is the whole store.
etcdctl compact 3408 # the current revision, from `etcdctl endpoint status --write-out=json`
etcdctl defrag
etcdctl snapshot save /var/lib/etcd/backup-enc2.db
strings etcd-snapshot-enc2.db | grep -c 'rotated-password'
compacted revision 3408
Finished defragmenting etcd member[127.0.0.1:2379]. took 269.489102ms
0
Only after a compaction and a defragmentation did the snapshot contain no plaintext (and three
k8s:enc:secretbox prefixes). The API server compacts automatically on an interval, so this window closes on
its own in a running cluster; a backup taken inside the window does not. The rule that follows: a backup has
the access controls of the most sensitive thing that was ever in it, and encryption at rest is what makes that
manageable, once the history has been compacted.
| Question | Check |
|---|---|
| Is there a backup of state, and is it recent? | etcdutl snapshot status; the object store listing for Terraform state versions |
| Does a restore work? | A restore into a scratch cluster or account, on a schedule, with the time recorded |
| Who can read the backup? | The bucket or volume policy; it should be a shorter list than who can read production |
| Can the application be rolled back without the backup? | kubectl rollout undo and a versioned artifact (Rollback and Recovery) |
| Is the infrastructure reproducible from source? | terraform plan on a clean checkout shows no drift |
Recovery has two shapes. Rolling back a deployment (the previous image, the previous ReplicaSet) needs no backup and is covered in the CI/CD path. Recovering state (a deleted namespace, a corrupted volume, a lost account) needs the backup, and the only evidence that it works is a restore that was actually performed. Terraform state on a versioned, access-controlled bucket, as Secure Terraform Foundations recommends, is the same principle for infrastructure: the state file is a backup of the mapping between code and reality, and it contains secrets.
The gap: backups contain everything. Section 4’s encryption at rest and Section 1’s access control on the backup location close it.
Dependency hygiene, briefly
Two items do not fit a layer and belong on the list anyway. Lockfiles with --frozen-lockfile installs, so
the build uses the dependencies review saw; and allow-listed build scripts, so a dependency cannot run code at
install time. Both are in place in this repository’s pnpm validate and are described in the
supply chain path. They are here because a
compromised dependency arrives with the application’s identity and inside every boundary above.
Not verified here
- Cloud-provider IAM behaviour, security group enforcement and VPC flow logs: the Terraform in Parts 1 and 2 was validated and scanned, not applied. The provider’s behaviour is documented, and the design is standard, but no account was used.
- Cloudflare WAF and security events: the managed ruleset is enabled for this site and the events view exists; no events were exported for this article.
- Restore: etcd snapshots were taken, inspected and compared; none was restored into a cluster in this run.
- Docker bridge isolation on Linux: not tested; the macOS host in Part 2 showed the opposite.
Security implications
- A checklist item without a command is an opinion. Every row above names one, and the ones that could not be run are listed under “not verified” rather than assumed.
- The controls overlap on purpose. When Part 2’s network boundary failed, Part 1’s password on the cache held; when Section 3’s CSP cannot see a compromised build dependency, Section 4’s lockfile and scan do. Removing a layer because another one “already covers it” removes the overlap, which is the design.
- Backups and audit logs are the two artifacts that outlive an incident and are read by the attacker’s successor as well as yours. They need the strictest access on the list.
Running this in production
- Turn the tables into a script that runs on a schedule and after every change, and fails on any row that
changed answer. Most rows are a
curl, akubectlor a scanner with an exit code. - Record the date each row was last verified next to the row. Rows nobody has run in a quarter are the ones to run first.
- Keep the “not verified” list honest. It is the list of controls whose failure you would not see, and it should shrink over time.
- Repeat Parts 1 to 4 on the next system before this list, not after it. The list is the summary; the parts are where the understanding is.
References
Keep reading