Two things today turned out to be one thing wrapped in another. You do not attach an IAM role to an EC2 instance, you attach an instance profile that holds the role. And an S3 bucket and the objects inside it are separate resources with separate ARNs, which is why a policy that looks complete can only do half the work.
One Docker task, one AWS task. Copy a file into a running container, then give an EC2 instance permission to use an S3 bucket without putting a single credential on disk. The tasks come from the KodeKloud Engineer platform.
docker cp, and where the file actually goes
docker cp /tmp/<file> ubuntu_latest:/opt/
There is no direction flag. The colon decides it: docker cp SRC container:DEST copies in, docker cp container:SRC DEST copies out. That is the entire syntax.
Two behaviours worth knowing before you rely on it. Docker does not create parent directories for the destination, so /opt has to already exist inside the container, which is why checking first is worth a command. And ownership is not preserved: files copied into a container are created owned by root, and files copied out are owned by whoever ran the command. docker cp -a preserves the source's uid and gid instead. An application running as a non-root user inside the container can find the file present and unreadable, which is a confusing five minutes if you did not expect it.
Then the verification, which is the habit rather than the command:
sha256sum /tmp/<file>
docker exec ubuntu_latest sha256sum /opt/<file>
Comparing ls -l on both sides proves the sizes match. It does not prove the bytes match. One extra command turns "it looks like it worked" into "it worked".
One structural point that leads into the next two days. The copied file lands in the container's writable layer. It is not part of the image, it does not survive docker rm, and a second container from the same image starts without it. Making a file part of an image means COPY in a Dockerfile, or committing the container. For a file that has to outlive the container and be shared with the host, the answer is a volume at run time, not a copy afterwards.
The wrapper nobody mentions
The AWS task was to let an EC2 instance read and write to one S3 bucket, via a role, with no access keys anywhere.
The console makes this feel like one step. Create a role, pick EC2 as the trusted entity, attach it to the instance. On the CLI, it is three resources, because you cannot attach a role to an instance at all:
aws iam create-role --role-name xfusion-role --assume-role-policy-document '...'
aws iam create-instance-profile --instance-profile-name xfusion-role
aws iam add-role-to-instance-profile --instance-profile-name xfusion-role --role-name xfusion-role
aws ec2 associate-iam-instance-profile \
--instance-id $EC2_ID --iam-instance-profile Name=xfusion-role
An instance profile is a container that holds exactly one role, and it is the thing EC2 actually accepts. The console creates one silently and gives it the same name as the role, which is why almost nobody knows it exists until the CLI says:
Invalid IAM Instance Profile name
That error reads like a typo. It means the resource does not exist.
Two ARNs, because they are two resources
Here is the policy, and the shape of it is the lesson:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject"],
"Resource": "arn:aws:s3:::xfusion-s3-458492027197/*"
},
{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::xfusion-s3-458492027197"
}
]
}
Two statements for three actions, because those actions operate on two different resource types. GetObject and PutObject act on objects, so the ARN carries /*. ListBucket acts on the bucket itself, so the ARN does not.
Collapse them into one statement, and exactly half of it works. With only the /* ARN, aws s3 cp succeeds and aws s3 ls returns AccessDenied. With only the bare ARN, the reverse. Both failures look like a broken policy in general rather than a resource-granularity mistake, which is what makes this worth committing to memory.
Note what is not in there: no s3:DeleteObject, no s3:*, no wildcard bucket. Put, get and list in one bucket.
It goes on as an inline policy rather than a managed one, because it is scoped to a single bucket and has no reason to be reusable:
aws iam put-role-policy --role-name xfusion-role \
--policy-name xfusion-s3-access --policy-document file://policy.json
Ask who you are before you ask what you can do
The instinct after attaching a role is to try the thing you wanted to do. Better to ask a smaller question first:
ssh root@$EC2_IP 'aws sts get-caller-identity'
{ "Arn": "arn:aws:sts::458492027197:assumed-role/xfusion-role/i-04e16a83d51bd745a" }
assumed-role/xfusion-role/<instance-id> is the confirmation. The instance pulled temporary credentials from the metadata service, and the session name is its own instance ID.
Splitting the check this way separates two failure modes that produce an identical AccessDenied: the role is not attached or has not propagated, so there is no identity at all; or the role is attached, and the policy is wrong. One extra command turns an ambiguous error into a specific one.
Then the actual proof:
upload: ./testfile.txt to s3://xfusion-s3-458492027197/testfile.txt
2026-08-28 04:25:41 23 testfile.txt
There is no ~/.aws/credentials on that instance, and aws configure was never run. The credential chain works through a list of sources in precedence order, with environment variables near the top, the shared credentials and config files in the middle, and EC2 instance metadata last of all. Everything above IMDS is empty here, so it falls through to the metadata service and gets temporary credentials that rotate on their own.
That is the real point of the task. A long-lived access key on an instance can leak through a backup, an AMI, a log, or a compromised process, and it stays valid until somebody notices. Role credentials expire by themselves and are scoped to one instance.
One regional oddity while we are in S3, since it costs people time:
aws s3api create-bucket --region us-east-1 --bucket $BUCKET
No --create-bucket-configuration. us-east-1 is the API default and passing a location constraint for it returns InvalidLocationConstraint. Every other region requires it.
Granularity is the whole game
The instance profile exists because EC2 needs a wrapper around a role. The two ARNs exist because a bucket and its contents are genuinely different things. In both cases, the abstraction that felt like one object is two, and the error message when you get it wrong describes the symptom rather than the structure.
So here is the Day 37 question. In the last IAM policy you wrote, do you know which statements act on a container and which act on its contents, or did it work the first time and you moved on?
Day 37 down. Sixty-three to go.
Top comments (0)