← Back to blog

Insecure by Default: The Docker and Terraform Traps

Infrastructure-as-code is a huge win: your environment is versioned, repeatable, and reviewable. It also means that if you encode one insecure default, you deploy it perfectly, every time, everywhere.

The usual suspects are a short list: containers running as root when they do not need to, hardcoded secrets in environment blocks and variable files, database and management ports exposed beyond localhost, storage buckets set to public, and credentials sitting in a state file. Some of these defaults have changed over the years and some have not, so each claim below was checked against the current vendor documentation in October 2026.

Why should a Docker container not run as root?

Because root is what you get when you do nothing, and it turns any bug in your app into root-level code execution inside the container. Docker's run reference states the default plainly: "The default user within a container is root (uid = 0)." One USER line in the Dockerfile removes that head start, at no cost.

Docker's security documentation makes the same point from the other direction. It describes containers as quite secure by default, and adds that this is especially true when processes inside run as non-privileged users. The default capability set is already reduced, but a root process can still write to every file in the image and to every volume you mount into it.

This is the Dockerfile that tutorials and AI assistants produce, because it works on the first try:

FROM node:latest
WORKDIR /app
COPY . .
ENV DATABASE_URL=postgres://postgres:postgres@db:5432/app
RUN npm install
EXPOSE 3000
CMD ["npm", "start"]

There are three problems in seven lines. The latest tag is mutable, so the same file builds a different image next month. The ENV line bakes a credential into the image, where anyone who can pull it can read it. And with no USER instruction, the app runs as root. The fixed version:

FROM node:22-slim@sha256:<digest>
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN groupadd -r app && useradd --no-log-init -r -g app app
USER app
EXPOSE 3000
CMD ["node", "server.js"]

The connection string is gone from the image and gets injected at runtime instead. If a real one was ever committed in a Dockerfile, treat it the way you would any other hardcoded secret and rotate it.

Pin the base image, then automate the bump

Docker's build best practices explain that tags are mutable and that pinning to a digest (the @sha256: suffix) guarantees you get the same image every time. The same page is honest about the tradeoff: a pinned digest also opts you out of automatic security fixes. The answer it suggests is to pin and let a tool such as Dependabot open the update, so each base image change is a reviewed commit. You can read the current digest for a tag with docker buildx imagetools inspect node:22-slim.

Does publishing a Docker port expose it to the internet?

By default, yes. Docker's port publishing documentation says that a port mapped without a host address is published on all host addresses, 0.0.0.0 and [::], and it calls this insecure by default. On a server with a public IP, a Compose line like "5432:5432" puts your database on the open internet.

A host firewall may not save you. Docker's packet filtering documentation explains that traffic to published ports is diverted in the nat table before it reaches the chains that ufw uses, so a ufw deny rule for that port does not apply to the container.

Now combine that with the other Compose habit. A docker-compose.yml with POSTGRES_PASSWORD: postgres is fine on your laptop and a gift to an attacker in production. Copied service definitions carry these defaults straight into deployment:

services:
  db:
    image: postgres
    environment:
      POSTGRES_PASSWORD: postgres
    ports:
      - "5432:5432"

The official Postgres image documentation says POSTGRES_PASSWORD is required and must not be empty, which is exactly why every example fills it with a placeholder. It also warns against the other shortcut, POSTGRES_HOST_AUTH_METHOD=trust, because that lets anyone connect without a password even when one is set. The safer file:

services:
  db:
    image: postgres:17
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    ports:
      - "127.0.0.1:5432:5432"

secrets:
  db_password:
    file: ./db_password.txt

Three changes. The image tag is pinned to a major version. The password comes from a Compose secret mounted at /run/secrets/db_password, which the Postgres image reads through its _FILE variables. Docker's Compose docs prefer secrets because environment variables are visible to every process in the container and tend to end up in debug logs. And the port is bound to 127.0.0.1, so only the host can reach it.

Often you can delete the ports block entirely. The Compose networking docs note that services on the same Compose network reach each other by service name on the container port, and that the host port only matters for access from outside. Your API can talk to db:5432 with nothing published at all. The same rule applies to debug and management ports: if only you need them, bind them to localhost and use an SSH tunnel.

Are new S3 buckets private by default?

Yes, now. AWS announced on 5 April 2023 that all new S3 buckets have Block Public Access enabled and ACLs disabled by default. A bucket only becomes public today when something deliberately turns those settings off and attaches a public policy, and in an infrastructure-as-code project that something is usually your own Terraform.

Older tutorials predate that change, and assistants trained on them still reach for the pattern that makes an AccessDenied error go away:

resource "aws_s3_bucket_public_access_block" "uploads" {
  bucket                  = aws_s3_bucket.uploads.id
  block_public_acls       = false
  block_public_policy     = false
  ignore_public_acls      = false
  restrict_public_buckets = false
}

resource "aws_s3_bucket_policy" "uploads" {
  bucket = aws_s3_bucket.uploads.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = "*"
      Action    = "s3:GetObject"
      Resource  = "${aws_s3_bucket.uploads.arn}/*"
    }]
  })
}

That makes every object in the bucket readable by anyone with the URL. There is a quieter version of the same mistake. In the AWS provider documentation, each of the four arguments on aws_s3_bucket_public_access_block defaults to false, so a block that sets only one or two of them leaves the rest switched off. Declare all four:

resource "aws_s3_bucket_public_access_block" "uploads" {
  bucket                  = aws_s3_bucket.uploads.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

AWS also recommends turning on all four settings at the account level, which covers buckets you forget. If users need to download private files, hand out short-lived presigned URLs from your backend instead of opening the bucket.

Ports open to the world: the 0.0.0.0/0 rule

Binding a database or admin service to 0.0.0.0 instead of localhost exposes it to the internet. In Terraform, an overly broad security-group rule (0.0.0.0/0 on a database port) does the same thing, quietly, in a file nobody re-reads.

This one is not a platform default. The AWS VPC documentation says a newly created security group has no inbound rules, so nothing gets in until you add one. Every open port in your account is a line somebody wrote:

resource "aws_security_group" "db" {
  name   = "db"
  vpc_id = aws_vpc.main.id

  ingress {
    from_port   = 5432
    to_port     = 5432
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

The fix is to name the one thing that should connect. AWS's own reference architecture allows 0.0.0.0/0 only on the load balancer, lets the web servers accept traffic only from the load balancer's security group, and lets the database accept traffic only from the web servers' group. The provider docs recommend the standalone rule resources for this:

resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
  security_group_id            = aws_security_group.db.id
  referenced_security_group_id = aws_security_group.app.id
  from_port                    = 5432
  to_port                      = 5432
  ip_protocol                  = "tcp"
}

While you are in that file, check the database resource. The provider documentation for aws_db_instance lists publicly_accessible as defaulting to false, which is the safe value, and storage_encrypted as defaulting to false, which is not. Set encryption explicitly.

The danger of IaC is not that it is hard. It is that it deploys your mistakes flawlessly.

Does Terraform store secrets in the state file?

Yes. HashiCorp's documentation says local state is a plaintext file that includes any secret values defined in your configuration, and that marking a value as sensitive only redacts it from CLI output. The value is still written to state and plan files, and anyone who can open those files can read it.

The AWS provider repeats the warning on the database resource itself: all arguments, including the username and password, are stored in the raw state as plain text. So this snippet leaks the password twice, once in the repository and once in terraform.tfstate:

resource "aws_db_instance" "main" {
  engine              = "postgres"
  username            = "postgres"
  password            = "postgres"
  publicly_accessible = true
}

There are three layers to the fix:

  • Never commit state. Add *.tfstate, *.tfstate.* and .terraform/ to .gitignore before the first apply. If a state file was ever pushed, deleting it is not enough, because git history keeps the old copy. Rotate everything in it.
  • Store state remotely, encrypted, with access control. HashiCorp recommends a backend that encrypts at rest, such as HCP Terraform or the S3 backend with the encrypt option enabled.
  • Keep the secret out of state altogether. Terraform 1.11 and later support write-only arguments, which HashiCorp documents as never persisted to state or plan files. On RDS you can also set manage_master_user_password = true and let RDS keep the password in Secrets Manager.
resource "aws_db_instance" "main" {
  engine                      = "postgres"
  username                    = "app_admin"
  manage_master_user_password = true
  publicly_accessible         = false
  storage_encrypted           = true
}

The checklist to run before you apply

  1. Every Dockerfile has a USER instruction and a base image pinned to a version or digest, with no latest.
  2. No ENV, Compose environment block or .tfvars file contains a real credential or a placeholder password.
  3. Every Compose ports entry is either deleted or prefixed with 127.0.0.1:, unless the service is meant to be public.
  4. No security group allows 0.0.0.0/0 on anything except 80 and 443 on a load balancer.
  5. Every S3 bucket has all four public access block settings set to true, and the account-level block is on.
  6. Databases set publicly_accessible = false and storage_encrypted = true.
  7. State lives in an encrypted remote backend, and git log --all -- "*.tfstate" returns nothing.

None of this is exotic. Security misconfiguration is its own category in the OWASP Top 10 for a reason. A function with no concurrency cap is the same class of mistake with a different consequence, covered in how one bug runs up a serverless bill. For everything outside the infrastructure files, there is a pre-launch security checklist for indie developers, and the rest of the app security guides are collected in one place.

Scan the config, not just the code

Application scanners look at your source. Your infrastructure is code too, and it deserves the same scrutiny. Checking your Dockerfiles, Compose files, and Terraform for default creds and open rules before you apply them is the cheapest infrastructure security you will ever buy, and the list above takes one sitting to work through by hand.

For the application side, the free IOnclad browser scanner runs a 167-rule subset entirely in your browser, with no signup. Your code stays in the tab. The IOnclad desktop app runs 24 scanners and 500+ checks across secrets and git history, dependencies, the OWASP web categories, session and auth, an API surface map, and a denial-of-wallet check, and returns one Ship-It verdict with the file, line and a fix for each finding. It does not promise your app is secure, and no scanner honestly can. If you are weighing tools, there is a separate page on how IOnclad compares with Snyk, Semgrep and Trivy.

Whatever you use, read the defaults before you trust them. Some have improved since the tutorial you copied was written. The ones in your own files have not.

Try it free
IOnclad

The pre-launch security scanner that answers "Am I safe to ship?" in under a minute. Runs 100% offline, free to start.

→
Keep reading
IOnclad Denial of Wallet: How One Bug Can Run Up a $10,000 Bill Read → IOnclad Your Git History Is Still Leaking That Deleted API Key Read → IOnclad The OWASP Top 10, Explained for Solo Developers Read →
Reading us on Google?
Add The IOn Project as a preferred source

One click on Google’s preferences page, and our articles show up more often in your Top Stories, AI Overviews, and AI Mode.

→