AWS CLI Cheatsheet
What it is
The AWS CLI is the command-line interface to every AWS service — aws s3 sync, aws ecs update-service, aws cloudformation deploy — and the closest thing AWS has to a deployment Swiss army knife. In a CI/CD pipeline it's the glue between your build artifact and the AWS resource that actually runs production: the bridge between "the container is in ECR" and "the new task definition is rolling out across ECS."
This sheet covers the commands and patterns you reach for when AWS CLI is part of your deploy script — multi-account profiles, role assumption for cross-account access, ECS rolling deploys, S3 + CloudFront cache busting, SSM Parameter Store for secrets, and tailing CloudWatch logs while a deploy runs. It assumes AWS CLI v2 is already installed — see how to install AWS CLI on Ubuntu and Linux if not.
Quick reference
Configuration and identity
aws configure # interactive: key, secret, region, output
aws configure --profile staging # write to a named profile
aws configure list # show where each setting is read from
aws configure list-profiles # all profiles in ~/.aws/credentials + ~/.aws/config
aws configure get region --profile staging # read one value
aws configure set region eu-west-1 --profile staging # write one value
aws sts get-caller-identity # who am I? (account + ARN)
aws sts get-caller-identity --profile staging
aws sts get-caller-identity --query Arn --output text # just the ARN
Pick credentials via env vars (CI / one-off) — overrides any ~/.aws/credentials value:
export AWS_ACCESS_KEY_ID=AKIA...
export AWS_SECRET_ACCESS_KEY=...
export AWS_SESSION_TOKEN=... # only with temporary credentials
export AWS_DEFAULT_REGION=us-east-1
export AWS_PROFILE=production # ignored when above keys are set
Assume a role (cross-account access)
aws sts assume-role \
--role-arn arn:aws:iam::222222222222:role/DeployFromCI \
--role-session-name ci-$(date +%s) \
--duration-seconds 3600
The response contains temporary AccessKeyId, SecretAccessKey, and SessionToken — export them and the next aws command runs under the assumed role. The named-profile equivalent (avoids the export dance) is in the workflows section.
S3
aws s3 ls # list buckets
aws s3 ls s3://my-bucket/path/ # list objects under a prefix
aws s3 mb s3://my-bucket --region us-east-1 # make bucket
aws s3 rb s3://my-bucket --force # remove bucket and all contents
aws s3 cp file.zip s3://my-bucket/path/file.zip # single-file upload
aws s3 cp s3://my-bucket/path/ ./local/ --recursive # bulk download
aws s3 cp file.zip s3://my-bucket/path/ --storage-class STANDARD_IA
aws s3 sync ./public s3://my-bucket/ \
--delete --exclude "*.map" --cache-control "max-age=31536000,public"
aws s3 mv s3://my-bucket/old.zip s3://my-bucket/new.zip
aws s3 rm s3://my-bucket/path/file.zip
aws s3 rm s3://my-bucket/path/ --recursive
aws s3 presign s3://my-bucket/private.zip --expires-in 3600 # signed download URL
aws s3 sync is the most useful subcommand by far — it only uploads/downloads changed files, and --delete keeps the destination in lockstep with the source (the right behaviour for a static-site deploy).
CloudFront
aws cloudfront list-distributions \
--query "DistributionList.Items[].{Id:Id,Domain:DomainName}" --output table
aws cloudfront create-invalidation \
--distribution-id E123ABC456DEF7 \
--paths "/*"
aws cloudfront create-invalidation \
--distribution-id E123ABC456DEF7 \
--paths "/index.html" "/assets/*"
aws cloudfront wait invalidation-completed \
--distribution-id E123ABC456DEF7 \
--id I1ABC2DEF3
Invalidating /* is fine but charges per path after the first 1,000/month. For a typical static-site deploy invalidate only the HTML files; the asset hashes change on each build so cache-busting is automatic for everything else.
EC2
aws ec2 describe-instances \
--query "Reservations[].Instances[].{Id:InstanceId,State:State.Name,IP:PublicIpAddress,Name:Tags[?Key=='Name']|[0].Value}" \
--output table
aws ec2 describe-instances \
--filters "Name=tag:Environment,Values=production" "Name=instance-state-name,Values=running" \
--query "Reservations[].Instances[].InstanceId" --output text
aws ec2 start-instances --instance-ids i-0abc123def456
aws ec2 stop-instances --instance-ids i-0abc123def456
aws ec2 reboot-instances --instance-ids i-0abc123def456
aws ec2 terminate-instances --instance-ids i-0abc123def456
aws ec2 describe-security-groups --group-ids sg-0abc123
aws ec2 authorize-security-group-ingress \
--group-id sg-0abc123 --protocol tcp --port 22 --cidr 203.0.113.10/32
SSM (Session Manager + Parameter Store)
# SSH replacement — no inbound port 22 needed, IAM-authenticated
aws ssm start-session --target i-0abc123def456
aws ssm start-session --target i-0abc123def456 \
--document-name AWS-StartPortForwardingSession \
--parameters '{"portNumber":["3306"],"localPortNumber":["13306"]}'
# Run a command on one or many instances
aws ssm send-command \
--instance-ids i-0abc123 i-0def456 \
--document-name "AWS-RunShellScript" \
--comment "Restart nginx after config change" \
--parameters 'commands=["systemctl reload nginx"]'
# Parameter Store: secrets and config the deploy script can read
aws ssm get-parameter --name "/myapp/prod/database_url" --with-decryption \
--query Parameter.Value --output text
aws ssm get-parameters-by-path --path "/myapp/prod/" --with-decryption \
--query "Parameters[].{Name:Name,Value:Value}" --output table
aws ssm put-parameter --name "/myapp/prod/redis_url" --value "redis://..." \
--type SecureString --overwrite
Secrets Manager
aws secretsmanager get-secret-value \
--secret-id "myapp/prod/database" \
--query SecretString --output text
aws secretsmanager create-secret \
--name "myapp/prod/api-key" \
--secret-string "$(openssl rand -hex 32)"
aws secretsmanager rotate-secret --secret-id "myapp/prod/database"
SSM Parameter Store and Secrets Manager overlap. Rule of thumb: Secrets Manager for things that need automatic rotation (RDS passwords, third-party API keys with SDK support), Parameter Store for everything else — it's cheaper and the access pattern is identical.
ECR (Elastic Container Registry)
# Docker login — the most common ECR command in a CI pipeline
aws ecr get-login-password --region us-east-1 \
| docker login --username AWS --password-stdin 111111111111.dkr.ecr.us-east-1.amazonaws.com
aws ecr describe-repositories
aws ecr describe-repositories --repository-names myapp \
--query "repositories[0].repositoryUri" --output text
aws ecr list-images --repository-name myapp \
--query "imageIds[].imageTag" --output text
aws ecr describe-images --repository-name myapp \
--query "sort_by(imageDetails,& imagePushedAt)[-5:].[imageTags[0],imagePushedAt]" \
--output table
aws ecr create-repository --repository-name myapp \
--image-scanning-configuration scanOnPush=true
aws ecr batch-delete-image --repository-name myapp \
--image-ids imageTag=old-build
ECS
aws ecs list-clusters
aws ecs describe-clusters --clusters production
aws ecs list-services --cluster production
aws ecs describe-services --cluster production --services myapp \
--query "services[0].{Desired:desiredCount,Running:runningCount,Status:status,TaskDef:taskDefinition}"
# The deploy command — rolling update to a new task definition
aws ecs update-service \
--cluster production --service myapp \
--task-definition myapp:42 \
--force-new-deployment
# Wait until the rollout has converged before treating the deploy as successful
aws ecs wait services-stable \
--cluster production --services myapp
aws ecs describe-task-definition --task-definition myapp \
--query "taskDefinition.{Image:containerDefinitions[0].image,Cpu:cpu,Memory:memory}"
aws ecs list-tasks --cluster production --service-name myapp \
--query "taskArns" --output text
Lambda
aws lambda list-functions --query "Functions[].FunctionName" --output text
aws lambda invoke --function-name myapp-worker \
--payload '{"key":"value"}' --cli-binary-format raw-in-base64-out \
/tmp/lambda-response.json && cat /tmp/lambda-response.json
aws lambda update-function-code --function-name myapp-worker \
--zip-file fileb://bundle.zip
aws lambda update-function-code --function-name myapp-worker \
--image-uri 111111111111.dkr.ecr.us-east-1.amazonaws.com/myapp-worker:%revision%
aws lambda publish-version --function-name myapp-worker \
--description "release %revision%"
CloudFormation
aws cloudformation list-stacks \
--stack-status-filter CREATE_COMPLETE UPDATE_COMPLETE \
--query "StackSummaries[].StackName" --output text
aws cloudformation describe-stack-events --stack-name my-app-stack \
--query "StackEvents[].{Time:Timestamp,Resource:LogicalResourceId,Status:ResourceStatus,Reason:ResourceStatusReason}" \
--output table
aws cloudformation deploy \
--template-file template.yaml --stack-name my-app-stack \
--capabilities CAPABILITY_IAM --no-fail-on-empty-changeset \
--parameter-overrides Environment=production ImageTag=%revision%
aws cloudformation describe-stacks --stack-name my-app-stack \
--query "Stacks[0].Outputs[].{Key:OutputKey,Value:OutputValue}" --output table
aws cloudformation delete-stack --stack-name my-app-stack
aws cloudformation wait stack-delete-complete --stack-name my-app-stack
The end-to-end CloudFormation deployment flow inside a DeployHQ Custom Action is covered in the Deploy with AWS CloudFormation guide.
CloudWatch Logs
aws logs describe-log-groups --query "logGroups[].logGroupName" --output text
aws logs tail /ecs/myapp --follow # tail like `tail -f`
aws logs tail /ecs/myapp --since 10m --filter-pattern "ERROR"
aws logs tail /aws/lambda/myapp-worker --since 1h --format short
aws logs start-query \
--log-group-name /ecs/myapp \
--start-time $(date -u -v-1H +%s) --end-time $(date -u +%s) \
--query-string 'fields @timestamp, @message | filter @message like /ERROR/ | sort @timestamp desc | limit 50'
aws logs tail --follow is the most useful command in this entire sheet during a deploy — open it in another terminal, push the deploy, watch the application emit errors in real time.
IAM
aws iam list-users --query "Users[].UserName" --output text
aws iam get-user --user-name deploy-bot
aws iam list-attached-user-policies --user-name deploy-bot
aws iam create-access-key --user-name deploy-bot
aws iam list-access-keys --user-name deploy-bot \
--query "AccessKeyMetadata[].{Id:AccessKeyId,Created:CreateDate,Status:Status}"
aws iam delete-access-key --user-name deploy-bot --access-key-id AKIA...
aws iam list-roles --query "Roles[?starts_with(RoleName,'Deploy')].RoleName" --output text
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::111111111111:user/deploy-bot \
--action-names s3:PutObject --resource-arns arn:aws:s3:::my-bucket/*
aws iam simulate-principal-policy is the right way to debug "why is this AccessDenied happening" — it tells you which policy statement allowed or denied a specific action, without you having to read every attached document.
Output and queries
aws ec2 describe-instances --output json # default
aws ec2 describe-instances --output table # human-friendly
aws ec2 describe-instances --output text # tab-separated, scriptable
aws ec2 describe-instances --output yaml
# --query: client-side JMESPath filtering — drastically smaller output
aws s3api list-objects --bucket my-bucket \
--query "Contents[?Size > \`1000000\`].[Key,Size]" --output table
# Disable the pager (default `less` on v2) for scripts
export AWS_PAGER=""
aws --no-cli-pager ec2 describe-instances
Deployment workflows (the moat)
1. Multi-account deploys via named profile + source_profile
Cross-account deploys are the canonical AWS CLI deploy pattern: a CI user in your operations account assumes a deploy role in each target account (staging, production), and AWS CLI rotates the temporary credentials transparently.
~/.aws/config:
[profile ci-base]
region = us-east-1
[profile staging]
role_arn = arn:aws:iam::222222222222:role/DeployFromCI
source_profile = ci-base
region = us-east-1
[profile production]
role_arn = arn:aws:iam::333333333333:role/DeployFromCI
source_profile = ci-base
region = us-east-1
mfa_serial = arn:aws:iam::111111111111:mfa/deploy-bot
~/.aws/credentials:
[ci-base]
aws_access_key_id = AKIA...
aws_secret_access_key = ...
Usage:
aws sts get-caller-identity --profile staging
# {
# "Account": "222222222222",
# "Arn": "arn:aws:sts::222222222222:assumed-role/DeployFromCI/botocore-session-..."
# }
AWS_PROFILE=production aws ecs update-service \
--cluster production --service myapp --force-new-deployment
The first command of a session prompts for the MFA token (on the production profile only — staging skips it). The CLI caches the assumed credentials in ~/.aws/cli/cache/ and reuses them until they expire — typically one hour, configurable via --duration-seconds on the role's trust policy.
For a deployment script, set AWS_PROFILE once at the top and every command in the script runs as the assumed role:
#!/usr/bin/env bash
set -euo pipefail
export AWS_PROFILE="${TARGET:-staging}"
aws sts get-caller-identity # sanity check — abort the deploy if wrong account
# ... rest of the deploy script
2. ECS rolling deploy from a CI pipeline
The "deploy a container to ECS" pattern: tag a new image, register a new task definition pointing at it, update the service, wait for the rollout to converge.
#!/usr/bin/env bash
set -euo pipefail
CLUSTER="production"
SERVICE="myapp"
REPO="111111111111.dkr.ecr.us-east-1.amazonaws.com/myapp"
TAG="${1:?usage: $0 <image-tag>}"
# 1. Confirm the image actually exists in ECR before disrupting production
aws ecr describe-images --repository-name myapp \
--image-ids imageTag="$TAG" > /dev/null \
|| { echo "image $TAG not in ECR" >&2; exit 1; }
# 2. Take the current task definition and produce a new revision with the new image
CURRENT_TD=$(aws ecs describe-services --cluster "$CLUSTER" --services "$SERVICE" \
--query "services[0].taskDefinition" --output text)
NEW_TD_JSON=$(aws ecs describe-task-definition --task-definition "$CURRENT_TD" \
--query "taskDefinition" \
| jq --arg img "$REPO:$TAG" '
.containerDefinitions[0].image = $img
| del(.taskDefinitionArn, .revision, .status, .requiresAttributes,
.compatibilities, .registeredAt, .registeredBy)
')
NEW_TD=$(aws ecs register-task-definition --cli-input-json "$NEW_TD_JSON" \
--query "taskDefinition.taskDefinitionArn" --output text)
echo "registered new task definition: $NEW_TD"
# 3. Roll out
aws ecs update-service \
--cluster "$CLUSTER" --service "$SERVICE" \
--task-definition "$NEW_TD" \
> /dev/null
# 4. Wait — services-stable polls until desired == running for 3 consecutive checks
echo "waiting for rollout to converge..."
aws ecs wait services-stable --cluster "$CLUSTER" --services "$SERVICE"
# 5. Report final state
aws ecs describe-services --cluster "$CLUSTER" --services "$SERVICE" \
--query "services[0].{Desired:desiredCount,Running:runningCount,TaskDef:taskDefinition}" \
--output table
Why the jq dance: register-task-definition rejects fields that AWS adds on registration (taskDefinitionArn, revision, etc.), so they must be stripped from the input. This is the single most common reason ECS deploy scripts written by hand fail on first run.
aws ecs wait services-stable is the right convergence check — it polls every 15 seconds, succeeds when the service is steady-state, and fails after 40 minutes. Don't try to roll your own polling loop.
3. Static-site deploy: S3 sync + CloudFront invalidation
The frontend-deploy pattern. The hash-named assets (CSS/JS bundles) get a long cache lifetime; the HTML files that reference them get invalidated on every deploy.
#!/usr/bin/env bash
set -euo pipefail
BUCKET="my-site-prod"
DIST_ID="E123ABC456DEF7"
# 1. Long-cache the immutable assets
aws s3 sync ./dist/assets/ "s3://$BUCKET/assets/" \
--delete \
--cache-control "max-age=31536000,public,immutable"
# 2. Short-cache (or no-cache) the HTML — these change on every deploy
aws s3 sync ./dist/ "s3://$BUCKET/" \
--delete \
--exclude "assets/*" \
--cache-control "max-age=0,no-cache,must-revalidate"
# 3. Invalidate only the HTML files at the edge — assets/* don't need invalidation
INVALIDATION_ID=$(aws cloudfront create-invalidation \
--distribution-id "$DIST_ID" \
--paths "/" "/*.html" "/index.html" \
--query "Invalidation.Id" --output text)
echo "invalidation $INVALIDATION_ID submitted"
# 4. Wait for the invalidation to complete (optional — usually 30-90 seconds)
aws cloudfront wait invalidation-completed \
--distribution-id "$DIST_ID" --id "$INVALIDATION_ID"
echo "deploy complete"
The two s3 sync calls produce different Cache-Control headers on different prefixes — that's what makes hash-named assets browser-cacheable forever (immutable is the key flag) while keeping the HTML fresh. Sync order matters: assets first, then HTML, because the HTML references the assets and you don't want a brief window where the HTML points at assets that aren't uploaded yet.
4. Read secrets from SSM Parameter Store at deploy time
Avoid putting secrets in environment files checked into git, in .env files copied during deploy, or anywhere persistent on disk. Read them from Parameter Store at deploy time and pipe them straight into the runtime.
#!/usr/bin/env bash
set -euo pipefail
# Bulk-fetch every parameter under /myapp/prod/ in one round trip
PARAMS=$(aws ssm get-parameters-by-path \
--path "/myapp/prod/" --recursive --with-decryption \
--query "Parameters[].[Name,Value]" --output text)
# Convert "/myapp/prod/database_url<TAB>postgres://..." into "DATABASE_URL=postgres://..."
while IFS=$'\t' read -r name value; do
key="${name##*/}" # strip the prefix path
key="${key^^}" # uppercase
echo "${key}=${value}"
done <<< "$PARAMS" > .env.production
# Hand off to whatever process consumes the env file
docker compose --env-file .env.production up -d
rm -f .env.production # don't leave secrets on disk longer than necessary
Three things this does that env-files-in-git don't: secrets stay in AWS (auditable via CloudTrail), the only thing the CI runner ever sees is a temporary file that gets deleted, and rotation in Parameter Store takes effect on the next deploy without a code change.
For runtime apps that can read SSM directly (most AWS SDKs can), skip the intermediate .env file entirely — give the application an IAM role with ssm:GetParametersByPath on the prefix and let it fetch at startup.
5. Tail CloudWatch logs during a deploy
The single most useful debug technique when an ECS/Lambda deploy fails to converge: tail the application logs in another terminal while the deploy is in flight.
# Terminal A: deploy
./deploy-ecs.sh v1.2.3
# Terminal B: live logs from the new tasks
aws logs tail /ecs/myapp --follow --since 1m --format short
When the deploy fails (the new tasks crash at start), the logs surface the actual cause — bad environment variable, missing migration, mis-tagged image — usually within 30 seconds. Without log tailing you wait for the ECS services-stable timeout (40 minutes) and then guess.
For a Lambda deploy, the equivalent is:
aws logs tail /aws/lambda/myapp-worker --follow --since 5m
# in another terminal, run the deploy
Pre-deploy hook: confirm the log group actually exists. New services sometimes deploy faster than the log group gets created, and aws logs tail fails silently:
aws logs describe-log-groups --log-group-name-prefix /ecs/myapp \
--query "logGroups[0].logGroupName" --output text \
| grep -q myapp || { echo "log group missing" >&2; exit 1; }
Common errors and fixes
| Error / symptom | Cause | Fix |
|---|---|---|
Unable to locate credentials |
No credentials in env or ~/.aws/credentials; wrong AWS_PROFILE |
aws configure list to see what the CLI is reading; export keys or set AWS_PROFILE |
ExpiredToken: The security token included in the request is expired |
Temporary credentials (assume-role / SSO) have aged out | Re-run aws sso login or call assume-role again; the CLI caches in ~/.aws/cli/cache/ |
AccessDenied on a single action |
IAM policy missing the action, or a Deny overriding it |
aws iam simulate-principal-policy --action-names <action> --resource-arns <arn> |
ThrottlingException |
Per-region API call quota hit | Add --cli-read-timeout 0 and let the SDK retry; for ECS/CloudFormation, throttle the script with sleep between API loops |
Could not connect to the endpoint URL |
Wrong region (us-east1 not us-east-1), or service not available in your region |
aws ec2 describe-regions to list valid region codes; specify with --region |
botocore.exceptions.NoRegionError |
No region in env, profile, or --region flag |
export AWS_DEFAULT_REGION=us-east-1 or add region = us-east-1 to the profile |
aws s3 sync keeps re-uploading unchanged files |
Different storage-class or content-type between source and destination triggers re-upload | Add --exact-timestamps and ensure --content-type/--storage-class are consistent across runs |
ResourceInUseException from CloudFormation |
A previous stack operation is still in progress | aws cloudformation describe-stack-events to see what's stuck; usually requires waiting |
Cannot exceed quota for StandardLowFrequencyAccessStorageBytes |
S3 storage class quota | Request a quota increase via Service Quotas console or change --storage-class |
An error occurred (InvalidAccessKeyId) |
Key ID has been deleted/rotated but old credentials are still cached | rm -rf ~/.aws/cli/cache/ and re-authenticate |
ECR docker login fails with denied |
The aws ecr get-login-password output got truncated by your shell history or alias |
Always pipe directly: `aws ecr get-login-password \ |
aws ecs wait services-stable hangs forever |
New tasks are crash-looping; service never reaches desired count | Run aws logs tail against the task's log group — the application error is in there |
Output paginated through less and breaks scripts |
AWS CLI v2 uses less as the default pager |
export AWS_PAGER="" or pass --no-cli-pager on a single command |
An error occurred (AccessDenied) when calling the AssumeRole operation |
The role's trust policy doesn't allow your principal | Read the role's Trust Relationships tab; the Principal must list your user/role |
| Region-specific resource not found | The resource exists in another region — CLI defaulted to wrong one | Add --region <correct>; for cross-region scripts, set per-command not per-shell |
Companion: full DeployHQ deploy workflow
For DeployHQ users running AWS commands as part of a deployment, you don't need to install AWS CLI on your deploy server at all. The Deploy with AWS CloudFormation guide covers the Custom Action protocol — DeployHQ runs amazon/aws-cli:latest inside a Docker container during deployment, so an aws s3 sync or aws ecs update-service step happens with a clean, up-to-date AWS CLI on every deploy, no host-side install to patch.
The full GitHub-to-AWS deploy pattern looks like: push to a branch → DeployHQ webhook fires → build pipelines produce the artifact (container image, static bundle, Lambda zip) → Custom Action runs the relevant AWS CLI commands from the recipes above → smoke test → aws ecs wait services-stable confirms convergence. If a release does go wrong, one-click rollback restores the previous build, and you can re-run a single aws ecs update-service --task-definition <previous-revision> to flip ECS back at the same time.
Start a free DeployHQ trial to wire aws-cli into a production deploy pipeline.
Related cheatsheets
- Docker cheatsheet — for the container build that feeds
aws ecrand the ECS task definitions. - Bash cheatsheet — for the
set -euo pipefailpatterns that hold the deploy scripts above together. - SSH cheatsheet — for
aws ssm start-session's role as an SSH replacement (no port 22 needed). - curl cheatsheet — for the post-deploy smoke tests that hit the new release.
- Cron and Crontab cheatsheet — for scheduling periodic AWS CLI tasks (snapshot rotation, log archival).
- Cheatsheets hub — every DeployHQ cheatsheet in one place.
Need help? Email support@deployhq.com or follow @deployhq on X.