Skip to content
ExponaBLOG
AWS FOR DEVELOPERS · DAY 097 / 100

Common AWS mistakes: a practical guide for developers

Mistakes I see again and again.

Expona Admin7 min read

Introduction

I've spent 5.5 years building production platforms on AWS, and I've learned that the difference between a secure, reliable system and a compromised one often comes down to a handful of preventable mistakes. Common AWS mistakes tend to repeat across teams—not because developers are careless, but because AWS's flexibility creates friction around security defaults. In this post, I'll walk through four critical mistakes I see again and again, and show you exactly how to avoid them.

Long-lived Access Keys in Code or CI

This is the mistake that keeps me awake at night. I've seen committed AWS access keys rotated within minutes of a GitHub push, and I've seen them stay live in public repositories for weeks. The pattern is always the same: a developer hardcodes credentials to test something locally, commits by accident, and suddenly anyone with access to your repo has the same permissions as that IAM user.

I learned this lesson early. On an e-commerce platform I built, a junior developer added temporary credentials to a config file to debug a payment integration. He pushed it on a Friday afternoon. By Monday morning, our billing team had flagged charges for compute resources we never spun up. Credential scrapers had already found the keys and used them to mine cryptocurrency. We rotated the keys, shut down the instances, and filed a billing dispute—but that's three hours of my Monday I won't get back.

The right approach: use IAM roles. If you're running code on an EC2 instance, an ECS container, or a Lambda function, you don't need keys at all. Attach an IAM role to that resource, and the AWS SDK automatically picks up temporary credentials from the instance metadata service. For local development, use the AWS CLI to assume a role and populate your ~/.aws/credentials file, or use AWS SSO to handle authentication without ever touching long-lived keys.

Here's what that looks like in practice:

bash
# Never do this:
export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY

# Do this instead:
aws sso login --profile production
aws sts assume-role --role-arn arn:aws:iam::123456789:role/DeveloperRole --role-session-name my-session

In your application code, rely on the SDK to pull credentials from the environment. The AWS SDK for Node.js, .NET, Python—all of them check the instance metadata service first. Don't ever hardcode.

For CI/CD pipelines, use OpenID Connect (OIDC) to exchange your GitHub token for temporary AWS credentials. No keys stored in secrets. GitHub Actions, GitLab CI, and others support this. Your pipeline gets a short-lived token, assumes a role with specific permissions for that workflow, and that's it.

Public S3 Buckets and Databases

S3 bucket misconfigurations are still the #1 source of data breaches I see in production. The issue: S3 defaults are secure, but one misconfiguration—or one developer who forgets to set a bucket policy—and suddenly your customer data is world-readable.

I worked on a SaaS platform that stored user profile documents in S3. We had a bucket for staging and a bucket for production. One Wednesday, a contractor was setting up a new feature and accidentally set the staging bucket's Block Public Access to false, then added a policy that allowed anyone to read objects. The bucket filled up with test files containing real user data. A security researcher found it and reported it responsibly, but the damage was already done—we had to notify customers, and our reputation took a hit.

The fix is defense in depth. Start here:

  • Enable "Block all public access" on every bucket by default. This is a single toggle that blocks public ACLs and public policies, and it should be your baseline.
  • Use bucket policies to explicitly grant access only to what needs it. If your application reads from S3, use IAM roles that assume a role with permissions scoped to specific buckets and prefixes.
  • Enable versioning and MFA delete to prevent accidental (or malicious) bucket wipes.
  • For databases, never expose RDS instances to the public internet. Use security groups to allow traffic only from your application tier, and keep databases in private subnets. If you need remote access, use AWS Systems Manager Session Manager instead of opening port 5432 or 3306.

Here's a sample bucket policy that grants read access only to a specific EC2 role:

bash
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::123456789:role/AppServerRole"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::my-app-bucket/user-data/*"
    }
  ]
}

For RDS, your security group should look like this: inbound traffic from your app tier only, on the database port (5432 for PostgreSQL), and nothing else. If you need to run a query from your laptop, use Session Manager to tunnel through a bastion, or use AWS Secrets Manager to rotate temporary credentials.

No Billing Alerts

This mistake burns money silently. You spin up an r5.4xlarge RDS instance for testing, forget about it, and three months later you've been charged $3,000. Or a developer misconfigures a Lambda function and it invokes 100 million times in a day. Or—like the credential scraper scenario I mentioned earlier—someone else uses your account to run compute.

I've seen a spike of $15,000 in a single day because a query was inefficient and generated terabytes of CloudWatch Logs. The team had no alerts, so nobody noticed until the billing cycle closed.

The solution is simple: set up billing alerts and actually monitor them. Use AWS Budgets to define spending thresholds and get notified when you exceed them. Set a budget for the month, set another for each major service (RDS, Lambda, EC2, S3), and tie those alerts to Slack or email so they actually reach you.

bash
# Create a budget via the AWS CLI:
aws budgets create-budget \
  --account-id 123456789 \
  --budget file://budget.json \
  --notifications-with-subscribers file://notifications.json

In the budget.json, define a monthly limit. In notifications.json, define thresholds (e.g., alert at 50%, 80%, and 100% of budget) and where to send them. Check your budgets weekly. It takes five minutes to set up and saves thousands in runaway costs.

Single-AZ Production Databases

This is the one that keeps teams up at night. You run a production RDS instance in a single availability zone, everything works fine for months, and then AWS performs maintenance and your database is down for an hour. Your customers can't place orders, your support team drowns in tickets, and you're scrambling to figure out why nobody has multi-AZ failover.

I learned this on a ride-hailing platform. We had a PostgreSQL RDS instance in us-east-1a, and during AWS maintenance window one Tuesday afternoon, the instance rebooted. Our application had no connection pooling configured, so all 500 connection attempts from our app servers hit the database at the same time during recovery. The database took 15 minutes to come back up. During those 15 minutes, riders couldn't book trips. We got paged, we got blamed, and the fix was obvious in hindsight: enable Multi-AZ.

Multi-AZ is not expensive. RDS Multi-AZ creates a synchronous standby replica in a different AZ, and if the primary fails, it automatically promotes the standby. Failover takes 1-2 minutes, and you're back online. The cost is roughly 2x the single-AZ price—so if your primary is $500/month, Multi-AZ is around $1,000. That's pocket change compared to the cost of an outage.

Enable it when you create the database, or enable it on an existing database (it causes a brief outage as the failover replica syncs). There is no reason to run production databases without Multi-AZ.

Scenario Single-AZ Multi-AZ
AWS maintenance Downtime (1-2 hours) Automatic failover (1-2 minutes)
AZ failure Database offline until manual recovery Automatic failover to standby
Cost (example) $500/month $1,000/month
Recovery time Hours Minutes

Also, backups matter. Enable automated backups with a retention period of at least 7 days. Take snapshots before major changes. Test your recovery process quarterly—not just trust that it works, but actually restore a snapshot and run your smoke tests against it. I've seen teams that thought their backups were solid, only to discover during a real incident that snapshots were encrypted with a key they no longer had access to.

Common Mistakes Summary

These four mistakes cluster around the same root cause: trading immediate convenience for long-term risk. A hardcoded key works now, until it doesn't. A public S3 bucket is easier than dealing with IAM policies, until someone scrapes it. No billing alerts mean you don't think about costs, until they spike. And single-AZ databases seem fine, until maintenance happens and customers lose money.

The pattern I see across all of them is that the secure, reliable approach takes slightly more work upfront, but saves weeks of pain later. Spend an hour setting up OIDC for CI/CD, and you never have to worry about leaked credentials. Spend two hours tightening S3 bucket policies, and you never have a data exposure incident. Spend 30 minutes configuring billing alerts, and you catch runaway costs before they hurt. Enable Multi-AZ once, and you sleep better knowing your database can survive maintenance.

Key Takeaways

  • Never hardcode or commit AWS access keys. Use IAM roles for EC2, ECS, Lambda; use SSO or OIDC for CI/CD and local development.
  • Block all public access on S3 by default. Use bucket policies and security groups to grant access only to what needs it. Keep databases in private subnets.
  • Set up AWS Budgets with alerts for monthly spending and per-service limits. Check them weekly and investigate anomalies immediately.
  • Enable Multi-AZ on all production databases. The cost is worth the automatic failover, and there is no excuse for single-AZ in production.
  • Test your backups and disaster recovery plan quarterly. Backup strategy means nothing if you can't actually restore.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles