How to Fix AWS CLI & Boto3 NoCredentialsError (Docker & Cron Metadata)

Logeshwaran.C

If boto3 throws NoCredentialsError: Unable to locate credentials inside a Docker container or a cron job, the fix is almost never about your actual AWS keys — it's about which process, which user, and which network hop is asking. And here's the part that trips up almost everyone: on an EC2 instance, the instance metadata service only answers requests that travel one network hop from the instance by default, and stepping into a Docker container counts as an extra hop — so the exact setup that works perfectly when you run your script directly on the box goes dark the moment you wrap it in a container.

⚡ Quick Answer

Running in Docker on EC2? → Raise the IMDS hop limit to 2 with aws ec2 modify-instance-metadata-options --http-put-response-hop-limit 2.

Running under cron? → Set HOME explicitly in the crontab, or point boto3 at credentials with AWS_SHARED_CREDENTIALS_FILE.

Running in ECS/Fargate? → Stop chasing EC2 metadata entirely — attach a task IAM role instead. See the ECS section.

Not sure which one applies to you? Start with the three-question diagnostic below — it takes less time than reading the rest of this page.

Jake found this out the expensive way. He runs a small phone repair and resale shop, and last spring he set up a nightly script — a Docker container, kicked off by cron on an old EC2 box — that was supposed to pull the day's inventory changes and drop a summary into an S3 bucket so his bookkeeper could reconcile stock without logging into anything. It worked beautifully the first time he ran it by hand from his terminal. Then he containerized it, scheduled it, and went home. Three weeks later his bookkeeper asked why there had been no inventory file since the day he set it up. Twenty-one nights of silent failures, twenty-one missed reconciliations, and a full day lost tracking down which of eleven phones sold that month nobody had properly logged.

‍♂️ Jake's Reality Check

"I gave the instance an IAM role. I tested it. It worked. How does the exact same script, on the exact same machine, suddenly not know who it is?"

Because "the exact same machine" wasn't true. A container has its own tiny network stack sitting behind the host's, and cron runs your script as a stripped-down process with almost none of the environment your interactive shell quietly hands you. Same box, two different worlds.

Why one error message hides three different problems

NoCredentialsError is boto3's way of saying "I checked everywhere I know to check, and none of them had anything." It is not a permissions error — that's a different exception (AccessDenied or ClientError) that fires after credentials are found but rejected. NoCredentialsError means the search itself came up empty. Under boto3 that search is called the credential provider chain: a fixed, ordered list of places boto3 looks, stopping the instant one of them produces something usable.

The reason Docker and cron both trigger this error, but for completely different reasons, is that each one quietly removes a different rung from that ladder. Cron strips almost your entire shell environment before your script ever runs. Docker gives your process a fresh, isolated environment that has never heard of your ~/.aws folder, and — if you're on an EC2 instance — puts an extra network hop between your code and the instance metadata service. Combine the two, as Jake did, and you've knocked out two separate rungs at once, which is exactly why "it works when I run it by hand" is not evidence of anything once cron or Docker enters the picture.

The boto3 credential chain, in the order it's actually checked

Before fixing anything, it helps to know the actual search order, because "just set an environment variable" only works if you set the right one in the right place. Boto3's official documentation lists the chain like this, stopping at the first source that returns something:

Order Source Why it fails silently in Docker/cron
1–2 Credentials passed directly to boto3.client() or Session() Only applies if your code hardcodes them — usually it doesn't, on purpose.
3 AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY env vars Neither cron nor docker run passes your shell's exports unless you tell them to.
4–6 Assume-role / web identity / IAM Identity Center providers Read from ~/.aws/config, so they inherit the same home-directory problem as row 7.
7 Shared credentials file (~/.aws/credentials) "~" resolves against whichever user and HOME the process actually has — often not the one you tested with.
9 Shared config file (~/.aws/config) Same home-directory issue as above.
11 Container credential provider (AWS_CONTAINER_CREDENTIALS_RELATIVE_URI) Only present automatically on ECS/EKS — a plain Docker container never gets this variable unless something sets it.
12 EC2 instance metadata service (IMDS) Last resort, and the one most affected by the extra network hop a container introduces.

Boto3's own developer guide confirms this exact order and confirms something worth sitting with: "Note that if you've launched an EC2 instance with an IAM role configured, there's no explicit configuration you need to set in Boto3 to use these credentials." That's the whole appeal of an instance role — zero config, on a bare EC2 instance. It's also exactly why it feels like sorcery when the identical role, on the identical box, stops working purely because your code now runs one layer deeper.

Cause 1: cron doesn't run your script as "you"

When you SSH into a box and type a command, your shell — bash, zsh, whatever — has already loaded a long list of environment variables: HOME, PATH, USER, and anything you've exported in .bashrc or .profile. Cron does none of that. It runs your job with a tiny, minimal environment, typically just SHELL, a bare-bones PATH, and HOME set to the crontab owner's home directory — but the crontab owner isn't always who you think. If you edited the crontab with sudo crontab -e without specifying a user, or if the job runs as root while you tested as ec2-user, boto3 goes looking for ~/.aws/credentials in root's home directory, finds nothing there, and moves on down the chain to the last resort — instance metadata, which may or may not be reachable depending on what's calling it.

There's a second, quieter variant of this. If your script activates a Python virtual environment or relies on a shell alias to set variables, cron never sources any of that — it runs your command with /bin/sh by default in most distributions, not your interactive shell, so anything defined only in .bashrc simply never happens.

 What people assume vs. what actually happens

  • Assumed: "cron runs my script exactly like I do when I SSH in and run it."
  • Actual: cron runs it as a near-bare process — usually with just SHELL, a minimal PATH, and HOME for whichever user owns that crontab.
  • What that means: any credential source that depends on your interactive shell's environment or home directory — env vars you exported by hand, a virtualenv you activated, a profile file only referenced via an alias — is invisible to the cron job unless the crontab line sets it explicitly.

Fixing the cron environment

  1. Confirm which user actually owns the crontab. Run crontab -l -u <username> for each candidate, or check /etc/crontab and /etc/cron.d/ if the job was added system-wide rather than per-user.
  2. Set HOME explicitly at the top of the crontab rather than trusting the default: HOME=/home/ec2-user as the first line, above your job entries.
  3. Use full, absolute paths everywhere — the Python interpreter, the script itself, and any file your script reads — since cron's minimal PATH often doesn't include /usr/local/bin where pip-installed tools live.
  4. Wrap the call so it inherits a real environment, e.g. bash -lc '/usr/bin/python3 /opt/app/sync.py', which forces bash to load your login profile before running the command.
  5. Log stdout and stderr to a file instead of letting cron mail it into the void: append >> /var/log/sync.log 2>&1 to the crontab line, so the next failure leaves a trail instead of twenty-one silent misses.

⚠️ What this actually breaks

A cron job with no output redirection doesn't just fail quietly — on most distributions it tries to mail the output to the crontab owner via the local mail transfer agent. If that isn't configured, the failure output goes nowhere at all. This is precisely how Jake lost three weeks: no error ever reached him, because there was nowhere for the error to land.

Should you replace cron with a systemd timer instead?

If your distribution runs systemd — the process manager most current Linux distributions use to start and supervise services — a systemd timer is worth considering as a cron replacement, not because it changes anything about the credentials problem, but because it removes the exact failure mode that hurt Jake. A systemd timer runs your script as a proper service unit, which means its output goes straight into the system journal by default instead of relying on a mail transfer agent that may not exist. You can check its last run and exit status at any time with systemctl status your-job.service, which is a much shorter path to "did last night's run actually succeed?" than digging through a log file you had to remember to redirect into. It's still worth setting Environment=HOME=/home/ec2-user in the unit file, for the exact same reason you'd set HOME in a crontab — the underlying credential-discovery problem doesn't go away just because the scheduler changed.

Cause 2: Docker containers start with a blank slate

A Docker container is a separate, isolated filesystem and process space. It does not automatically see your host's ~/.aws/credentials file, your exported environment variables, or anything else about your account — you have to hand each of those in deliberately, the same way you'd hand secrets to a stranger rather than assume they already know them. If your Dockerfile — the plain-text recipe Docker reads to build an image — doesn't COPY a credentials file in and your docker run command doesn't pass any -e flags or -v mounts, the container's boto3 has absolutely nothing to work with from rows 3 through 9 of the chain — it falls all the way through to rows 11 and 12: the container credential provider (which won't exist unless you're on ECS/EKS) and instance metadata (which is where the hop-limit problem below comes in).

People solve this three different ways, and they are not equally good ideas:

Method Works in cron too? Use it when
Pass env vars with -e / env_file Yes, if cron sets them first Quick local testing, or CI runners that already inject secrets this way.
Mount ~/.aws read-only with -v Yes Developer laptops where the host already has a working profile via IAM Identity Center or an assumed role.
Let the container reach instance metadata (IMDS) N/A — EC2-only Production containers running directly on an EC2 host with an attached instance profile.
Bake keys into the image Technically, yes Never. Keys baked into an image layer sit there forever, even after you delete the file in a later layer.

✅ Why this is the one to use

On an EC2 instance, letting the container reach the instance metadata service is the right default. There's nothing to leak into version control, nothing to rotate by hand, and the credentials automatically expire and refresh on their own. The only thing standing between "it just works" and "NoCredentialsError" is the hop limit — which is exactly the next section.

Getting it right in Docker Compose specifically

Docker Compose — the tool that lets you define several related containers in one compose.yaml file instead of typing a long docker run command by hand — adds its own layer of "which value actually wins," and it's a common place for this exact error to sneak back in after you thought you'd fixed it. If you set the same variable in more than one place, Docker's own documentation lays out a strict order of precedence, from highest to lowest: a value passed with docker compose run -e on the command line wins first; then a value interpolated from your shell or an .env file into either the environment or env_file attribute; then a plain value set directly with the environment attribute; then a file referenced with env_file; and last, whatever the image itself set with an ENV instruction at build time.

The practical trap this creates: if you set AWS_PROFILE=default as a literal value under environment: in your compose file, and separately have an old, stale AWS_ACCESS_KEY_ID sitting in a .env file at the root of your project, Compose will quietly substitute that stale key into the container's environment because shell-and-.env-file interpolation sits above a plain environment: value in the precedence order. Your fix looks correct in the compose file, but the container is still authenticating as whatever the leftover .env file says.

A safe Compose pattern for the metadata-based approach

If you're taking the recommended route — letting the container reach the EC2 instance's metadata service rather than passing static keys — there's nothing special to add to your compose file at all. Don't set any AWS_ACCESS_KEY_ID anywhere in it; the absence of rows 3 through 9 in the credential chain is exactly what lets boto3 fall through to the instance metadata provider. The only thing to check is that the service isn't accidentally running with a custom bridge network configuration that adds yet another hop on top of Compose's own default bridge network — if you've hand-rolled a multi-network Compose setup, verify the hop limit against however many container-network boundaries a request actually crosses, not just one.

Keeping credentials out of the image itself

There's a version of this mistake that happens at build time rather than run time, and it's worth flagging separately because it doesn't produce NoCredentialsError at all — it produces the opposite problem, a working credential that shouldn't exist anywhere. If your Dockerfile needs AWS credentials to do something during the build itself — for example, pulling a private artifact from S3 as part of setting up the image — using a plain ARG or ENV instruction to pass that key in leaves it sitting in the image's build history permanently. Docker's own documentation is direct about the fix: BuildKit, Docker's modern build engine, supports a dedicated --secret flag specifically so a credential is available only for the single build step that needs it, and is never written into any image layer at all.

  1. Pass the secret into the build, not the Dockerfile. On the command line: docker build --secret id=aws,src=$HOME/.aws/credentials . — the file's contents never appear in your Dockerfile source.
  2. Mount it for exactly the step that needs it. In the Dockerfile: RUN --mount=type=secret,id=aws,target=/root/.aws/credentials aws s3 cp s3://my-bucket/setup-file . — the file exists at that path only while this one RUN instruction executes.
  3. Never follow it with a COPY . . that could recapture it. A secret mount avoids writing the file to a layer, but a later instruction that copies your whole build context can still capture a credential file if it happens to sit inside that context — keep credential files out of the build context entirely, or exclude them with a .dockerignore entry.
  4. Verify with docker history. Run docker history --no-trunc your-image after the build and confirm no layer shows the credential value in its command string — this is the actual proof that the secret mount worked as intended.

⚠️ What this actually breaks

Docker images are layered, and "deleting" a file in a later instruction does not remove it from an earlier layer — the earlier layer is still part of the image, still exportable as a tarball, and still readable by anyone who can pull the image or inspect its history. A key passed through a plain ARG is exactly as permanent as if you'd committed it to source control, even though the Dockerfile looks clean at a glance.

This is a build-time problem, distinct from everything else in this post about run-time credential discovery — but it belongs here because the instinct that causes it is the same instinct that causes NoCredentialsError in the first place: assuming the container already knows something it was never actually told, or accidentally telling it in a way that leaves a permanent trace.

The metadata path, explained properly: why the hop limit is the real culprit

Every EC2 instance can reach a special, non-routable address — 169.254.169.254 — that answers questions like "what IAM role is attached to me" and "what are my current temporary credentials." This is the instance metadata service, or IMDS, and it's the reason an EC2 instance with a role attached needs zero hardcoded keys at all: boto3's last-resort credential provider just asks IMDS for a token.

There are two versions of this service. IMDSv1 is a plain, unauthenticated GET request. IMDSv2 requires first requesting a short-lived session token with a PUT request, then including that token on every subsequent metadata call — a defense against a class of attacks where something on the box gets tricked into leaking metadata responses without meaning to. AWS's own guidance says the latest versions of every SDK support IMDSv2, and current AWS accounts increasingly default new instances to requiring it.

Here's the part that actually causes the Docker-specific version of this error. AWS's EC2 documentation states plainly: by default, the response to PUT requests has a response hop limit of 1 at the IP protocol level. That PUT request is exactly the token request IMDSv2 needs. A "hop," in this context, isn't about internet distance — it's about how many times the packet crosses a network boundary. A process running directly on the EC2 host is zero hops from the metadata endpoint. A process running inside a Docker container, behind the container's own virtual network interface, is one hop further out — and with the default hop limit of 1, that extra hop is exactly one hop too many. The PUT request for the IMDSv2 token simply never gets a reply.

What makes this especially sneaky is what happens next. Several AWS SDKs, when the IMDSv2 token request gets no response, quietly retry using the older, tokenless IMDSv1 — which still works from inside the container regardless of the hop limit, because it never needed a PUT in the first place. That retry adds a delay, and depending on your SDK version and timeout settings, that delay can be long enough that the credential provider gives up before it succeeds, which is exactly what produces an intermittent-feeling NoCredentialsError that seems to fail "sometimes" rather than consistently.

 What changed: IMDSv1 to IMDSv2

  • Before: IMDSv1 answered any plain GET request with no token needed — simple, but vulnerable to being tricked into leaking metadata from inside the instance.
  • Now: AWS recommends IMDSv2, which requires a token obtained via a PUT request first, and account-level defaults increasingly require it for newly launched instances.
  • What that means for containers: the token PUT request is subject to the hop limit; a plain metadata GET under IMDSv1 is not as strictly affected by it in the same way, which is part of why some older container setups "worked" without anyone tuning the hop limit at all.

Fixing the hop limit on an existing instance

You don't need to relaunch the instance to fix this. AWS's EC2 documentation covers changing the metadata options on a running instance directly through the CLI:

  1. Confirm the instance ID: aws ec2 describe-instances --filters "Name=tag:Name,Values=your-instance-name" --query "Reservations[].Instances[].InstanceId".
  2. Raise the hop limit and keep the endpoint enabled in one call: aws ec2 modify-instance-metadata-options --instance-id i-1234567890abcdef0 --http-put-response-hop-limit 2 --http-endpoint enabled. AWS's documentation is explicit that when you set the hop limit, you must also set http-endpoint to enabled in the same command, or the change won't take effect the way you expect.
  3. Verify it took: aws ec2 describe-instances --instance-ids i-1234567890abcdef0 --query "Reservations[].Instances[].MetadataOptions".
  4. From inside the running container, confirm the token request now succeeds: curl -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600" should return a token string instead of hanging or timing out.
  5. Re-run your script without changing anything else in the code, and confirm boto3 now resolves credentials on the first attempt rather than falling back to IMDSv1 after a delay.

A hop limit of 2 is the number AWS's own guidance points to for container environments — enough to cover one layer of container networking without opening the door further than it needs to be. If your setup nests containers inside containers, or runs through something like an ECS-on-EC2 bridge network with additional network address translation, you may need 3; AWS's security team has publicly suggested testing with a hop limit of 3 in container-heavy environments if 2 isn't sufficient, though most single-layer Docker setups only need 2.

⚠️ What this actually risks

A bigger hop limit means metadata responses can travel further across your network before AWS stops forwarding them — which is exactly the property a hardening-focused setup wants to keep as small as possible. If your container runs code you don't fully trust (a public-facing app, a library with unreviewed dependencies), consider the container credential provider approach used by ECS instead of widening IMDS access, or explicitly block 169.254.169.254 from that specific container's network path while allowing it elsewhere.

A three-question diagnostic before you touch anything

Before applying any fix, spend two minutes answering these, in order — most people jump straight to a fix that solves the wrong layer of the problem, which is how a five-minute issue turns into an afternoon.

  1. Does the error happen when you run the script manually, as the same user, in the same shell the container/cron job will use? If yes, this isn't a Docker or cron issue at all — go back to basics with aws sts get-caller-identity.
  2. Is this a plain Docker container on a bare EC2 instance, or is it running under ECS, Fargate, or EKS? The three managed platforms have their own dedicated credential delivery mechanisms that are not instance metadata at all, and trying to fix a plain-EC2-style problem on ECS wastes time chasing the wrong endpoint.
  3. Is the failure constant (every single run) or intermittent (sometimes works, sometimes doesn't)? Constant failures are almost always a missing environment variable or an unmounted credentials file. Intermittent failures that seem to depend on timing are almost always the IMDSv2-token-timeout-then-fallback pattern described above.

ECS and Fargate: stop fighting IMDS, use a task role

If your "Docker container" is actually a task running under Amazon ECS (on EC2 or Fargate), instance metadata is the wrong thing to be debugging entirely — and AWS explicitly recommends against containers reaching the underlying EC2 instance's metadata service at all in this setup, since a task shouldn't be able to see the host's own credentials. Instead, ECS gives each task its own dedicated, isolated credentials endpoint at 169.254.170.2, completely separate from the EC2 metadata address. When you attach a task IAM role to your task definition, the ECS agent automatically injects an environment variable called AWS_CONTAINER_CREDENTIALS_RELATIVE_URI into your container, and boto3's container credential provider — row 11 in the chain table above — picks it up with no other configuration needed.

This is a meaningfully better setup than an EC2 instance profile shared across every container on the box, because AWS's own ECS documentation notes that on EC2, EC2 instance profiles are not available for containers in Fargate tasks at all — task roles are the only mechanism that works across both launch types. It also means each task gets its own scoped-down permissions instead of every container on a shared instance getting whatever the instance role allows, which is a real security improvement, not just a convenience one.

If you're running ECS on EC2 (not Fargate) with an older, non-optimized AMI, there's some extra plumbing AWS's documentation calls out: your container instance needs specific iptables rules to route traffic to 169.254.170.2 toward the agent's local credential proxy, and the agent needs ECS_ENABLE_TASK_IAM_ROLE=true set for bridge-mode networking, or ECS_ENABLE_TASK_IAM_ROLE_NETWORK_HOST=true for host-mode networking. The current ECS-optimized AMIs handle all of this automatically; it's only a manual step if you built your own AMI from scratch. Windows containers on EC2 that use task roles need their own additional bootstrap script to route both 169.254.170.2 and 169.254.169.254 correctly, and reserve port 80 on the container instance for the credential proxy — worth knowing if your fleet is mixed Linux and Windows.

What about EKS?

Kubernetes pods on EKS have their own separate mechanism for this same problem — pods get temporary credentials tied to a Kubernetes service account rather than reaching either EC2 metadata or the ECS container-credentials endpoint directly. If you're seeing this error on EKS specifically, the fix path is different enough from both the plain-Docker and ECS cases above that it deserves its own dedicated troubleshooting rather than a quick aside here — check that your pod's service account is correctly associated with an IAM role before assuming the problem is the same as a bare-EC2 container.

Testing locally: laptops, WSL2, and Docker Desktop don't have IMDS at all

It's worth stating plainly, because it trips up people testing before they deploy: instance metadata is an EC2 feature. It does not exist on your laptop, inside WSL2 (the compatibility layer that runs a real Linux environment on Windows), or inside Docker Desktop's own virtual machine — there is no EC2 instance underneath any of those, so there is nothing at 169.254.169.254 to answer a request in the first place. If you develop locally and deploy to EC2, this means the fix that works in production (letting the container reach IMDS) is never going to work on your machine, no matter how you tune hop limits, because there's no metadata service present to tune.

The practical answer for local development is to accept that you're using a different credential source than production on purpose: mount your host's ~/.aws folder read-only into the container, or use whichever profile your organization has set up through IAM Identity Center, and treat "does it resolve credentials via IMDS" as something you only ever verify once the container is actually running on EC2 — a staging instance, if you have one, rather than your laptop.

Confirming the fix actually worked — not just "it ran without erroring"

A script that runs without throwing NoCredentialsError isn't necessarily using the credentials you think it's using. It's entirely possible for a leftover environment variable, an old mounted file, or a forgotten profile setting to quietly satisfy an earlier link in the chain than the one you just fixed — which means your careful hop-limit change did nothing, and you just haven't noticed yet because something else in the chain happened to work. Before calling it fixed, run this inside the actual container, under the actual cron job if you can:

aws sts get-caller-identity

The response tells you the exact identity boto3 is currently authenticating as — the account, the user or role ARN, and the caller's unique ID. If that ARN matches the role you expected (your task role, your instance profile, whichever you were aiming for), the fix is real. If it shows a different identity than expected, or an access-key-based user instead of a role, something upstream in the chain is still quietly satisfying an earlier provider — go back to the table above and check rows 3 through 9 for anything left over from an earlier troubleshooting attempt.

Automating the check so it never silently breaks again

The one-off sts get-caller-identity check above is useful once, by hand — but the entire reason this post exists is that a broken credential path can run silently for weeks. Build the check into the thing that fails, not into your memory of needing to check it.

  1. Add it as the very first line of your script's entry point using boto3's own STS client, and exit with a non-zero status code immediately if it raises: a five-line guard clause that turns "silent failure" into "the process visibly died on line one."
  2. If the script runs in Docker, wire the same check into a HEALTHCHECK instruction in the Dockerfile, so docker ps shows the container as unhealthy the moment credential resolution starts failing, rather than only when the next scheduled task happens to run.
  3. If the script runs under cron or a systemd timer, log the result of that check to the same place your other output goes — the redirected log file, or the systemd journal — so the very first thing you see when you go looking is whether identity resolution succeeded, before anything else in the script even started.

A lot of forum answers to this exact error jump straight to "just pass your access key as an environment variable" and call it solved. That does work — row 3 in the chain fires before instance metadata ever gets a chance — but it quietly trades a self-rotating, zero-maintenance credential for a static one that someone now has to remember exists, remember to rotate, and remember not to commit to a Dockerfile or a docker-compose.yml that ends up in version control. If you're already on an EC2 instance with a role attached, or already on ECS with a task role, hardcoding keys is very rarely the right long-term answer — it's a workaround that happens to be easy to paste into a forum reply, not a workaround that's easy to live with six months later when nobody remembers which script has which key baked in.

‍♂️ Jake's Reality Check

"So why not just bake the access key into the image and be done with it? It's one line."

Ethan's answer was blunt: "Because that one line lives forever. Docker images are layered — even if you delete the key in a later step, it's still sitting in an earlier layer, retrievable by anyone who can pull the image or inspect its history. I've seen a leaked key in a public image get used within hours of being pushed. The IAM role costs you nothing extra and it can't leak, because it was never written down anywhere to begin with."

Do you need a third-party credential tool on top of this?

Once you've got the underlying chain fixed, it's fair to ask whether you also need one of the popular open-source wrappers built around this exact problem — tools that hold your long-lived credentials in an OS keychain and inject short-lived, auto-refreshing ones into a process only for the duration it runs. They exist for a real reason, but they solve a different problem than the one this post is about.

Situation Worth adding a wrapper tool?
Production container on EC2/ECS with a role attached No — the instance/task role already does this job with zero extra tooling.
Developer laptop, multiple AWS accounts, frequent manual role-switching Often yes — it keeps long-lived secrets out of plaintext files on disk and handles session refresh for you.
A single cron job on a single server with one profile No — it adds a dependency and a moving part to solve a problem an IAM role already solves for free.

The honest rule: these tools earn their place when a human is regularly switching between AWS identities by hand. They add very little when the thing needing credentials is a server-side process that could just as easily have a role attached to it directly — in that case, the wrapper tool is solving a problem the IAM role already solved, at the cost of one more thing to install, patch, and troubleshoot.

What no amount of boto3 configuration can fix

Being straight about the limits here matters more than another clever workaround. If the underlying IAM role or user genuinely has no policy attached — or the wrong one — no credential-chain fix changes that. You'll stop seeing NoCredentialsError and start seeing AccessDenied or a ClientError with an explicit "not authorized to perform" message instead, which is progress, not failure: it means credentials were found, and now it's a permissions question, not a discovery question. Similarly, if the instance role was only just attached seconds before your script ran, IAM's own eventual-consistency behavior means the metadata service can take a short moment to reflect the change — a script that ran one second after aws ec2 associate-iam-instance-profile can legitimately still see the old (or no) role. And if you don't know the account's root or admin credentials and the IAM role's trust policy doesn't permit the identity you're using to assume it, that's not a boto3 configuration problem at all — that's a policy decision someone with the right access needs to make.

Keeping this fix from becoming next month's security finding

Once the error disappears, it's tempting to move on. A few habits are worth building in at the same time you fix the immediate problem, because they're much cheaper to set up now than to retrofit after an audit flags them:

  • Add .aws/ and any credentials file to .dockerignore — the file Docker reads to decide what never gets sent into the build context — so a stray COPY . . in your Dockerfile can never accidentally ship a mounted or locally-tested credentials file inside the image.
  • If you mounted ~/.aws into a container for local testing, mount it read-only (:ro) so a compromised process inside the container can't rewrite your host's credentials file.
  • Keep the hop limit at the smallest number that actually works for your setup — 2 for a single layer of Docker, not a wide-open number "just in case," per the caution AWS gives in its own container-environment guidance.
  • On ECS, prefer a task role scoped to exactly what that one task needs over a broad container-instance role shared by everything running on the host — this is precisely the separation-of-concerns benefit AWS's ECS task role documentation describes.
  • If a process genuinely has no business calling AWS APIs at all, setting AWS_EC2_METADATA_DISABLED=true in that container's environment stops boto3 from even attempting an IMDS call, which both removes a fallback failure mode and closes off a class of server-side request forgery risk against the metadata endpoint.
  • Remember that anyone who can run docker inspect or docker exec against a running container can typically read whatever environment variables you passed in with -e — treat those the same way you'd treat a plaintext credentials file sitting on disk, not as something inherently safer just because it's an environment variable.

The complete checklist, start to finish

Environment First thing to check Fix
Plain script, no container, no cron aws sts get-caller-identity Fix credentials the normal way first — nothing container-related applies yet.
Cron job on any host Which user owns the crontab, and what's HOME set to Set HOME explicitly, use absolute paths, redirect output to a log file (or move to a systemd timer).
Plain Docker on your laptop / WSL2 Did you pass -e flags or a -v mount Mount ~/.aws read-only, or pass env vars explicitly — IMDS won't help here at all.
Plain Docker on EC2 curl the IMDSv2 token endpoint from inside the container Raise the hop limit to 2 with modify-instance-metadata-options.
Docker Compose on EC2 Any leftover keys in an .env file overriding your intended source Remove stray AWS_* values from .env and environment:; let the chain fall through to IMDS.
ECS on EC2 or Fargate Is a task IAM role attached to the task definition Attach a task role — don't touch EC2 metadata settings at all.
EKS pod Is the pod's service account linked to an IAM role Fix the service-account-to-role association; this is a separate mechanism from both rows above.

The one habit that prevents you from ever debugging this twice

Jake's real mistake wasn't the hop limit — plenty of careful engineers hit that exact wall the first time they containerize something on EC2. It was that his script's only failure signal was silence. Add one cheap health check to any scheduled AWS script, container or not: have it call sts.get_caller_identity() as its very first action and exit loudly — a non-zero exit code, a line in a log file, ideally a notification — if that call fails, before it attempts anything else. That single check turns "twenty-one silent missed nights" into "one failed run and an alert the next morning," and it costs about three lines of code.

Ethan's take on the bigger picture: "People treat this error like a one-time puzzle to solve and move past. It isn't. Every new environment you deploy into — a new EC2 box, a new ECS cluster, someone's laptop — is a fresh chance to hit the exact same wall, because the wall isn't really about credentials. It's about which of five very different delivery mechanisms your process happens to be standing in front of that day. Learn the mechanisms once, and the error stops being scary."

Jake's fix, once he found it, took about ten minutes: one CLI command to raise the hop limit, one line added to the top of his crontab, and three lines wrapping his script's first call in a check that now emails him the moment it fails. His bookkeeper hasn't missed a night since — and the next time something in his stack goes quiet, he'll know within a day, not three weeks.

Frequently asked questions

What does "Unable to locate credentials" actually mean in boto3?

It means boto3 walked through its entire credential provider chain — explicit parameters, environment variables, config files, container credentials, and finally instance metadata — and none of them returned anything usable. It's a discovery failure, not a permissions failure; a permissions problem would surface as AccessDenied instead, after credentials were successfully found.

Why does my script work locally but fail in Docker?

Locally, boto3 is likely finding credentials in your shell's environment variables or your ~/.aws/credentials file. A Docker container starts with neither of those unless you explicitly pass them in with -e flags or a volume mount — the container has no idea your host machine has a working AWS profile.

Why does my script work when I run it manually but fail under cron?

Cron runs your job with a minimal environment — typically just SHELL, a bare PATH, and HOME for whichever user owns that crontab entry, which may not be the user you tested as. It doesn't load your .bashrc, doesn't activate a virtualenv you source manually, and doesn't inherit anything you only exported in an interactive shell session.

What is "the metadata path" people mean when they talk about this error?

It refers to boto3's last-resort credential source: a request to the EC2 instance metadata service at 169.254.169.254, which returns the temporary credentials tied to whatever IAM role is attached to the instance. "The metadata path" failing usually means either that address isn't reachable at all, or the IMDSv2 token request specifically isn't getting a response because of the hop limit.

What is the EC2 instance metadata service (IMDS) and why does boto3 use it?

IMDS is a service that runs locally on every EC2 instance, reachable only from that instance, that exposes information about the instance itself — including temporary, automatically-rotating credentials for whatever IAM role is attached. Boto3 uses it as the final fallback so that code running on an EC2 instance with a role never needs hardcoded keys at all.

What's the difference between IMDSv1 and IMDSv2 for this error?

IMDSv1 is a plain, tokenless GET request. IMDSv2 requires first requesting a session token with a PUT request, then presenting that token on every subsequent call — a change made specifically to reduce a known attack pattern. The PUT request for that token is the part affected by the hop limit; a container one hop away from the metadata service can fail to get a token even while the older IMDSv1 GET might still succeed.

Why does raising the hop limit fix Docker containers but not fix cron?

Because they're different failures at different layers. The hop limit only affects network-level requests to the metadata service, which is a Docker-and-EC2-specific problem. A cron job's issue is almost always about which user and home directory the process runs under — a purely local, filesystem-and-environment problem that has nothing to do with network hops.

Do I need to change anything for a container running with --network host?

Containers using host networking share the host's network stack directly rather than getting their own virtual interface, which generally means they're not adding the same extra hop that bridge-mode networking does. It's still worth verifying with a token-request curl test from inside the container rather than assuming, since behavior can vary by Docker version and host configuration.

How is the ECS/Fargate container credentials endpoint different from EC2 IMDS?

They're entirely separate services at separate addresses. EC2 IMDS lives at 169.254.169.254 and serves the instance's own role. ECS's container credentials endpoint lives at 169.254.170.2 and serves credentials scoped to an individual task's role, delivered via an AWS_CONTAINER_CREDENTIALS_RELATIVE_URI environment variable the ECS agent injects automatically — no hop-limit tuning involved.

Should I mount my ~/.aws/credentials file into the container?

For local development, it's a reasonable shortcut — just mount it read-only so a compromised process inside the container can't rewrite your host credentials. For production on EC2, an instance role reachable through the metadata service is the better long-term choice, since it needs no file to manage, leak, or rotate by hand.

Is it safe to set AWS_EC2_METADATA_DISABLED=true?

It's safe and often a good idea — for a process that has no legitimate reason to call AWS APIs at all, this stops boto3 from even attempting an IMDS request. Set it on the wrong container, though, and you'll turn a fixable network issue into a permanent one, since you've told the SDK not to try that path in the first place.

Why does aws configure fix it for my user but not for the cron job?

aws configure writes credentials to the shared credentials file under the current user's home directory. If your cron job runs as a different user — or as the same username but with HOME unset or pointed elsewhere — it's looking in a different, empty file, even though the command name and username look identical in the crontab.

How do I check which credential provider boto3 is actually using?

Run aws sts get-caller-identity in the exact same context — same container, same cron invocation if possible — where your script runs. The returned ARN tells you the true identity, which lets you confirm it matches the role or user you intended rather than something left over from an earlier fix attempt.

I get AccessDenied, not NoCredentialsError — is that the same problem?

No, and it's actually progress. AccessDenied means credentials were found and boto3 successfully authenticated as some identity — the problem now is that the identity's attached policy doesn't grant the specific action you're calling. That's a policy fix, not a credential-discovery fix.

Does this happen on EKS/Kubernetes pods too?

Yes, but through a different mechanism entirely — EKS pods get credentials tied to a Kubernetes service account rather than reaching EC2 instance metadata directly. If you're seeing this on EKS, the fix is checking the service account's IAM role association, not adjusting the EC2 hop limit or ECS container credentials settings.

What is the single most reliable long-term fix instead of chasing this error every time?

Stop hardcoding, stop guessing, and match the credential mechanism to the platform: an instance role with a correctly tuned hop limit for plain Docker on EC2, a task role for ECS/Fargate, and a service-account role binding for EKS. Add an early sts.get_caller_identity() check to every scheduled script so a broken credential path fails loudly on the first run instead of silently for three weeks.

Revision note. Written September 2026, Vendor defaults and SDK fallback timing occasionally shift, so if a future release changes this behavior, the underlying diagnostic steps here — checking the credential chain order and confirming identity with sts.get_caller_identity() — will still point you in the right direction. If you've lost sleep over a silently failing cron job like Jake did, you're not missing something obvious; this error genuinely hides several unrelated problems behind one message, and now you know which one is yours.

Related