aws cli
You've built an expense app that extracts merchant names, dates, and totals from receipt photos. It works locally. Now a few businesses want to use it, which means it needs to stay online when your laptop is closed.
You don't need every AWS service to launch it. You need somewhere to run the API, somewhere to keep the receipts, and a database. More services can earn their place when the app encounters a problem they solve.
Amazon launched S3 and EC2 in 2006, making object storage and rented compute available through APIs. AWS grew into a much broader collection of managed services. The AWS Command Line Interface, made generally available in 2013, gives many of those APIs a common command format: aws <service> <operation> [options]. These examples use CLI v2.
Launching the receipt API
You install the CLI and configure the development account through your organization's IAM Identity Center setup:
aws --version
aws configure sso --profile receipts-dev
aws sso login --profile receipts-dev
export AWS_PROFILE=receipts-dev
export AWS_DEFAULT_REGION=ap-south-1
aws sts get-caller-identityThe account and principal in that last response should be the ones you intend to use. A profile selects identity/configuration; a region selects the regional endpoint. Neither grants permission: IAM decides which operations the identity can perform. The exported variables apply to this shell, so you don't have to repeat them on every command below.
The examples create some resources and inspect others. They assume a development account with permission to perform the operations. Names such as the launch template, database, repository, and cluster refer to resources your infrastructure setup must supply; they aren't built-in AWS defaults. Running resources incurs charges, and the example commands aren't a complete production provisioning script.
The first deployment runs the API on EC2, a virtual machine you control. Your reviewed launch template includes the AMI, instance size, subnet, security groups, and instance role. You launch one instance from its recorded version:
aws ec2 run-instances \
--launch-template LaunchTemplateName=receipts-api,Version=1 \
--count 1 --query 'Instances[0].InstanceId' --output text
aws ec2 describe-instances \
--filters Name=tag:Service,Values=receipts Name=instance-state-name,Values=running \
--query 'Reservations[].Instances[].{Id:InstanceId,AZ:Placement.AvailabilityZone,IP:PrivateIpAddress}' \
--output tableInclude the Service=receipts instance tag in that template for the inventory query to find it. Service-side --filters narrow the result before it is returned; --query uses JMESPath to shape the response locally. An empty table can mean the instance is still starting, the tag is absent, or the account/region is wrong.
You install and deploy the app through your image or delivery pipeline. EC2 being running doesn't establish that the API is ready. For administrative access, an instance configured with the SSM agent, network connectivity, and the right role can use Session Manager rather than an SSH port exposed to everyone.
Storing photos and queuing OCR
At first, receipt files sit on the server's disk. That is manageable for one machine, but the next API instance needs the same uploads, and replacing a server shouldn't relocate the whole file collection. You move the images to S3, which stores objects addressed by bucket and key.
Choose a globally unique bucket name. This creation command uses the Mumbai region selected above; us-east-1 has different bucket-creation syntax:
receipts_bucket="YOUR_GLOBALLY_UNIQUE_BUCKET_NAME"
aws s3api create-bucket --bucket "$receipts_bucket" \
--create-bucket-configuration LocationConstraint=ap-south-1
aws s3api put-public-access-block --bucket "$receipts_bucket" \
--public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
aws s3 cp ./receipt-42.jpg "s3://$receipts_bucket/incoming/receipt-42.jpg"
aws s3api head-object --bucket "$receipts_bucket" --key incoming/receipt-42.jpg
aws s3 presign "s3://$receipts_bucket/incoming/receipt-42.jpg" --expires-in 300Supply an actual local image for the upload. head-object returns metadata; the presigned URL grants temporary access to that object within the signing identity's permissions. Anyone holding the URL can use it while valid. The bucket stays private, and the app uses its instance role rather than a long-lived access key committed with the code.
A month later, customers upload a whole month's receipts at once. OCR takes seconds per image, and doing it inside the HTTP request ties up API workers. You introduce SQS so the API can enqueue a receipt ID and return while background workers process the backlog:
queue_url=$(aws sqs create-queue --queue-name receipt-ocr \
--attributes VisibilityTimeout=120 \
--query QueueUrl --output text)
aws sqs send-message --queue-url "$queue_url" \
--message-body '{"receipt_id":"receipt-42","key":"incoming/receipt-42.jpg"}'
aws sqs receive-message --queue-url "$queue_url" \
--wait-time-seconds 20 --max-number-of-messages 1The worker downloads the image, runs OCR, and stores the result. Only after success does it call delete-message with the receipt handle returned by this receive operation:
aws sqs delete-message --queue-url "$queue_url" --receipt-handle 'RECEIPT_HANDLE_FROM_RECEIVE'Replace that placeholder with the returned handle. Receiving doesn't permanently remove a message; it hides it for the visibility timeout. If processing takes longer, extend visibility or choose a suitable timeout. Standard SQS queues can deliver duplicates and out of order, so processing the same receipt must be safe to retry. A dead-letter queue handles repeatedly failing messages; you monitor it rather than silently abandon those receipts.
Moving Postgres and deploying containers
The business records are still relational: accounts own receipts, reviewers approve them, and reimbursements group approved expenses. Your Postgres process on a VM works, but operating backups, patching, and recovery is taking time away from the app. You move it to RDS for PostgreSQL, retaining the schema while adopting a managed database service.
After provisioning and migrating to an RDS instance called receipts-db, you inspect its endpoint:
aws rds describe-db-instances --db-instance-identifier receipts-db \
--query 'DBInstances[0].{Status:DBInstanceStatus,Endpoint:Endpoint.Address,Port:Endpoint.Port}'The CLI manages RDS resources; SQL clients and drivers still query Postgres. Network rules, credentials, migrations, connection pools, and tested restores remain part of your work. A Multi-AZ deployment supports availability; read replicas serve a different purpose and can lag. Neither feature removes the need for backups.
Once the app runs several API and OCR worker versions, you package them as containers and push tested images to ECR, AWS's container registry. For an existing repository, the CLI can identify the digest behind a release tag:
aws ecr describe-images --repository-name receipts \
--image-ids imageTag=1.0.0 --query 'imageDetails[0].imageDigest' --output textECR stores images; it doesn't run them. Your team already operates Kubernetes and needs the same workload policies across several services, so EKS becomes an option. ECS, including with Fargate, could be a simpler fit for a team without that Kubernetes requirement. Growth alone doesn't make EKS compulsory.
The platform provisions an EKS cluster with compute, networking, and access configured. You add its connection information locally and inspect the application:
aws eks update-kubeconfig --name receipts-dev --alias receipts-dev
kubectl --context receipts-dev -n receipts get deployments,podsupdate-kubeconfig changes your local kubeconfig; it doesn't create the cluster or grant Kubernetes access. EKS manages the Kubernetes control plane, while the selected compute setup runs the Pods. The workload still needs probes, resource settings, and appropriate AWS permissions. Worker scaling should follow backlog and processing time, rather than assume CPU is the only useful signal.
Tracking receipt status at scale
Large clients now poll job progress frequently. This record is a small item fetched by a known receipt ID, and its frequent updates needn't compete with relational approval transactions. You try DynamoDB for that status view:
aws dynamodb create-table --table-name receipt-status \
--attribute-definitions AttributeName=receipt_id,AttributeType=S \
--key-schema AttributeName=receipt_id,KeyType=HASH \
--billing-mode PAY_PER_REQUEST
aws dynamodb wait table-exists --table-name receipt-status
aws dynamodb put-item --table-name receipt-status \
--item '{"receipt_id":{"S":"receipt-42"},"state":{"S":"queued"}}' \
--condition-expression 'attribute_not_exists(receipt_id)'
aws dynamodb get-item --table-name receipt-status \
--key '{"receipt_id":{"S":"receipt-42"}}' --consistent-readDynamoDB's CLI JSON marks attribute types: S is a string. The condition prevents replacing an existing status on creation. It doesn't make the entire OCR workflow exactly once. The worker updates status deliberately, and reconciliation can rebuild it from durable job records. Strong reads here are for the base table in its region; consistency and indexing choices elsewhere need their own design.
You keep approvals and reimbursement rules in Postgres. DynamoDB fits this keyed status workload; it isn't a reason to move every relational query into table scans. If Postgres comfortably handles the status traffic, an additional database may not earn its operational cost at all.
During a failed batch, you follow the same receipt ID through the API log, SQS message, worker log, and stored result. With the application's log collection configured, CloudWatch Logs makes a bounded investigation repeatable:
aws logs tail /apps/receipts --since 10m --followS3 holds the photos, SQS buffers the work, RDS holds the business records, and the chosen compute runs the app. Alarms cover failed jobs, queue age, API errors, and database pressure. A load balancer and horizontal scaling address API availability as demand grows; a CDN can serve public static assets without making private receipts public.
At month end, a backlog of uploads no longer means customers holding open HTTP requests while OCR runs. You can see the age of the queued work, add worker capacity, and follow a failed receipt by ID. The app uses AWS SDKs and scoped roles for those operations; the CLI is what you reach for to inspect the resources, reproduce a request, or investigate the next failed batch. Once the setup is settled, its infrastructure changes go through reviewed configuration such as Terraform.