Your Containers Aren't Secure. Here's What to Actually Do About It.

| 5 min read |
containers docker kubernetes security

Containers give you process isolation, not a security boundary. I break down how we hardened images, locked down runtimes, and segmented networks at the fintech startup — plus the stuff nobody warns you about.

Quick take

Containers ship code. They don’t ship security. Harden the image, lock the runtime, segment the network, or accept that one escape owns your entire node.

Containers Aren’t a Security Boundary

I need to say this bluntly because I keep hearing it wrong: a container isn’t a VM. It isn’t a sandbox. It shares a kernel with the host. If something breaks out, the blast radius is the whole node. Full stop.

At the fintech startup we learned this the hard way during an early Kubernetes migration. We had a service running as root inside its container — “just for debugging,” someone said — and a misconfigured volume mount gave it read access to the host’s /etc. Nobody exploited it. We caught it in review. But it sat there for two weeks before anyone noticed, and that scared me more than an actual breach would have.

A principle from NATO cyber defense that applies everywhere: assume the perimeter is already broken. Then design so that a breach in one layer doesn’t cascade. Containers are just another layer. Treat them that way.

Ship Less, Expose Less

The single highest-ROI thing you can do for container security is shrink your images. Every package you include is attack surface. Every binary is a tool an attacker can use post-exploitation. Multi-stage builds are your best friend here.

FROM golang:1.9 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o service .

FROM alpine:3.6
RUN adduser -D appuser
USER appuser
COPY --from=builder /app/service /service
ENTRYPOINT ["/service"]

This is almost exactly what we use at the fintech startup for our Go services. Builder stage compiles everything. Final image has Alpine, a non-root user, and the binary. That’s it. No curl, no bash, no wget. If an attacker gets code execution inside this container, they have almost nothing to work with.

Two more things that matter:

Pin to digests, not tags. alpine:3.6 can change underneath you. alpine@sha256:abc123... can’t. We had a CI build pass on Tuesday and fail on Thursday because the base image got a patch that broke our libc expectations. Tags are lies. Digests are truth.

Scan continuously. Not just at build time. Vulnerabilities get published weeks or months after an image ships. We run Clair against our registry on a cron. Anything with a critical CVE gets flagged and the owning team has 48 hours to patch or explain why not.

Runtime: Where Mistakes Become Incidents

A clean image means nothing if you run it with --privileged. I’ve seen production deployments where containers had SYS_ADMIN capabilities because someone copy-pasted a Stack Overflow answer during a late-night deploy. That one capability basically gives you root on the host.

Strip everything. Start from zero and add back only what you need.

securityContext:
  runAsNonRoot: true
  readOnlyRootFilesystem: true
  capabilities:
    drop: ["ALL"]

Three lines of YAML. That’s it. runAsNonRoot stops the “just for debugging” excuse. readOnlyRootFilesystem prevents an attacker from dropping binaries. Dropping all capabilities means no mount, no raw sockets, no ptrace. If your app genuinely needs a specific capability — like NET_BIND_SERVICE to bind port 443 — add that one back explicitly and document why.

Pair this with seccomp profiles if your runtime supports them. Default Docker seccomp blocks around 44 syscalls. For most web services, you can block even more. We profiled our API gateway’s syscall usage over a week, built a whitelist, and cut the available syscall surface by 60%.

The other half is detection. You need to know when something unexpected happens inside a container. A new shell process. An outbound connection to an IP that isn’t in your dependency list. A binary that wasn’t in the original image. We keep this simple — sysdig + alerting rules. Nothing fancy. But it works because we actually look at the alerts.

Network Segmentation: Kill Flat Networks

Default Kubernetes networking is flat. Every pod can talk to every other pod. This is the container equivalent of putting every server in the same VLAN with no firewall rules. Insane, but it’s the default.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: api-allow-frontend
spec:
  podSelector:
    matchLabels:
      app: api
  policyTypes:
    - Ingress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: frontend
      ports:
        - port: 8080

This policy says: the API pod accepts traffic from frontend pods on port 8080. Everything else is denied. Lateral movement goes from trivial to impossible unless the attacker also compromises the frontend service.

At the fintech startup we started by mapping every service-to-service dependency, then wrote deny-all policies and added explicit allows. Took about a week for our cluster. Found three services that were talking to things they had no business talking to — leftover from an old feature that got half-removed. Network policies are a security tool and an architecture audit tool.

Service meshes with mutual TLS are starting to appear — Istio, Linkerd — but they’re early. We’re watching, not adopting yet. For secrets, keep them out of your images. Kubernetes Secrets with encryption at rest, or Vault if you want something more robust. Never ENV a database password into a Dockerfile. I’ve seen it. In production. At companies you’ve heard of.

RBAC and Audit Logging

Kubernetes RBAC isn’t optional. Neither is PodSecurityPolicy. These are the controls that prevent a developer with deploy access from accidentally (or intentionally) escalating to cluster-admin. Scope permissions to namespaces. Give teams access to their stuff and nothing else.

Audit logging closes the loop. When something goes wrong — and it will — you need to answer: who did what, when, and to which resource. Without audit logs, incident response is guesswork. With them, it’s a timeline.

The Real Point

None of this is exotic. Slim images, pinned builds, non-root containers, read-only filesystems, dropped capabilities, network policies, RBAC, audit logs. It’s a checklist, not a research project. The hard part isn’t knowing what to do. It’s actually doing it consistently, across every service, on every deploy, even when someone says “we’ll fix it after launch.”

Stack these controls and a container escape becomes a dead end instead of a highway to your entire infrastructure.