How to Manage Secrets in AWS ECS Without Hardcoding Anything
Every startup that's been on Railway or Render has gotten comfortable with environment variables. You add a DATABASE_URL to your dashboard, it shows up in your container, done.
When you move to AWS ECS, that same workflow exists โ but there are two ways to do it: the way that will get you breached eventually, and the way that actually works in production.
This guide explains both, why the second one matters, and how ECS's native Secrets Manager integration makes it easier than it sounds.
The Problem with Hardcoded Secrets
Let's be concrete about what "hardcoded secrets" means in an ECS context.
If your ECS task definition looks like this:
"environment": [
{ "name": "DB_PASSWORD", "value": "my-actual-password" }
]
That password is now in your task definition โ which is stored in AWS and potentially in your Terraform state file, your git history, and your CI/CD logs. Anyone with read access to your AWS account, your repo, or your deployment pipeline can see it.
This isn't theoretical. The most common startup breach pattern is: developer accidentally commits env vars โ secret ends up in git history โ repo gets public or internal access is too broad โ password gets scraped.
The right model is: secrets live in one secure place (Secrets Manager), and your containers receive them at startup without the values ever appearing in your task definition or any config file.
How ECS Secrets Manager Integration Works
AWS built this natively into ECS. Here's what happens when your task starts:
- ECS reads your task definition, which contains an ARN reference to a secret (not the value)
- The ECS agent fetches the actual secret value from Secrets Manager just before starting the container
- The secret gets injected as an environment variable
- Your application reads it like any other env var โ
process.env.DB_PASSWORD
The key point: the task definition only contains arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/db-password โ not the actual password. If someone reads your task definition, they see an ARN. The value never leaves Secrets Manager in a form that can be accidentally logged or committed.
The Setup in Plain Steps
Step 1: Store your secret
aws secretsmanager create-secret \
--name prod/database/credentials \
--secret-string '{"password":"your-password","username":"dbuser"}'
You can store secrets as plain strings or as JSON objects. JSON is better when you have related values (username + password + host) because you can reference individual keys from the same secret.
Step 2: Wire it in your task definition
In your container definition, use secrets (not environment) for anything sensitive:
"secrets": [
{
"name": "DB_PASSWORD",
"valueFrom": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/database/credentials:password::"
},
{
"name": "DB_USERNAME",
"valueFrom": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/database/credentials:username::"
}
]
The :password:: at the end tells ECS to extract the password key from the JSON secret. The two trailing colons are for version stage and version ID โ leave them empty to always get the latest version.
Non-sensitive config (database name, port, region) can still go in environment as plain values. The rule is: anything you'd be embarrassed to see on your screen in a public Slack message goes in secrets.
Step 3: Give the execution role permission
This is where most tutorials lose people. ECS has two roles:
- Task execution role โ what ECS itself can do when starting your task (like fetching secrets)
- Task role โ what your running application can do (like reading from S3)
Secrets injection happens before your container starts, so it needs the execution role, not the task role. This trips people up constantly.
Your execution role needs:
{
"Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue"],
"Resource": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/*"
}
Scope the resource to your specific secrets prefix โ don't give *. If your service only needs prod/database/credentials, only give access to that. Least-privilege isn't just security theater; it limits blast radius if something goes wrong.
SSM Parameter Store: When to Use It Instead
AWS has two options for storing config: Secrets Manager and SSM Parameter Store. Here's when to use each:
Secrets Manager is for actual secrets: database passwords, API keys, JWT secrets, OAuth credentials. It costs ~$0.40/secret/month, supports automatic rotation, and encrypts at rest with KMS by default.
SSM Parameter Store is for non-secret config that you still want centralized: API endpoints, feature flags, environment names. Free for standard parameters. Use the SecureString type if the value is sensitive but doesn't need rotation.
In practice: database credentials and third-party API keys go in Secrets Manager. Config like NEXT_PUBLIC_API_URL or LOG_LEVEL goes in regular environment variables or SSM.
The Rotation Problem ECS Engineers Always Miss
Secrets Manager supports automatic rotation โ you can set your database password to rotate every 30 days.
Here's the catch: running ECS tasks don't pick up rotated secrets automatically. The secret gets injected once when the task starts. If the password rotates while your task is running, your container still has the old password.
To handle rotation, you need to force a new deployment after rotation:
aws ecs update-service \
--cluster my-cluster \
--service my-service \
--force-new-deployment
The right way to automate this is a Lambda function triggered by the Secrets Manager rotation event that calls update-service on any service that uses the rotated secret. If you're not using rotation yet, don't let this stop you from using Secrets Manager โ just be aware of it when you enable rotation.
What This Looks Like With NoahOps
If you're setting up ECS infrastructure yourself, the above is what you need. If you're using NoahOps, this is handled for you.
When you create a service in NoahOps, you add environment variables in the UI โ and mark which ones are secrets. NoahOps creates the Secrets Manager entries, wires up the execution role permissions, and references them correctly in the task definition. You never write an ARN or touch an IAM policy.
When you update a secret value in NoahOps, it updates the Secrets Manager entry and triggers a redeployment so the running containers pick it up.
The difference: a startup engineer setting this up from scratch for the first time is looking at 2โ3 hours of reading AWS docs, debugging IAM permission errors, and figuring out the ARN format. In NoahOps, it's a text field.
Common Mistakes to Avoid
Putting the secret value in Terraform. If you create a secret with Terraform and set the value in the config, that value ends up in your Terraform state file in plaintext. Create the secret resource in Terraform (to manage the ARN and permissions), but set the value via the console, CLI, or a secret management tool that doesn't write state files.
Forgetting the random suffix. Secrets Manager appends a random 6-character string to secret ARNs (e.g., prod/db-password-aB1cD2). If you're referencing the ARN directly, include this suffix. You can also reference secrets by name in some contexts, which avoids this.
Giving the task role Secrets Manager access instead of the execution role. Your application doesn't need to call Secrets Manager โ ECS does that for you. The task role should only have permissions for what your running application actually calls (S3 buckets, SQS queues, etc.).
Using the same secret across all environments. Keep production, staging, and preview secrets separate. Different ARNs, different access policies. This way a compromised staging secret doesn't affect production, and you can rotate environments independently.
FAQ
My app is currently on Railway and uses environment variables in the dashboard. What changes on ECS?
The workflow is similar โ you set values in a UI (either AWS console or NoahOps), they appear as env vars in your container. The difference is Railway stores them as plaintext associated with your project; ECS + Secrets Manager stores them encrypted in a dedicated secret store with IAM-controlled access. Your app code doesn't change.
Do I need a KMS key to use Secrets Manager?
No โ Secrets Manager encrypts secrets at rest using AWS-managed KMS keys by default, at no extra cost. You only need a customer-managed KMS key if you have compliance requirements around key ownership or need to use the same key across multiple AWS accounts.
How do I reference the same secret in multiple services?
Create the secret once and reference its ARN in each service's task definition. Give each service's execution role access to the secret ARN. You can also use wildcard patterns in the IAM policy (prod/shared/*) if multiple services share a secret namespace.
What happens if Secrets Manager is down when my task starts?
ECS will fail to start the task and retry according to your service's deployment configuration. Secrets Manager has 99.99% SLA and is available across multiple AZs, so this is rare. Your application doesn't depend on Secrets Manager after startup โ only during task initialization.
Can I use this for API keys that rotate frequently?
Yes, but remember the rotation caveat: running tasks keep the value at startup time. If you have an API key that rotates daily, you either need to accept that running tasks use the previous key until they restart, or design your application to re-fetch the secret at runtime (which requires the task role to have Secrets Manager access). For most API keys, triggering a new deployment after rotation is the cleaner pattern.
Request a free demo at noahops.com to see how NoahOps handles secrets, environment configuration, and ECS infrastructure without the IAM docs spelunking.