The AWS provider reads a bucket's tags with `ListTagsForResource` on S3 Control when it knows the account ID. S3 Control puts the account in the hostname — `https://{account}.{endpoint}` — so against an IP-addressed endpoint the request fails a DNS lookup before a packet leaves, and no endpoint configuration can help. A tagged bucket therefore fails its first read after being created. `skip_requesting_account_id` alone does not fix it: `aws-sdk-go-base` takes the account ID from the STS `GetCallerIdentity` credentials check before it consults that flag, so `skip_credentials_validation` is needed alongside it. With no account ID the provider falls back to `GetBucketTagging`, which predastore serves. `s3-webapp` is the only workbook that creates a bucket, and it uses no `aws_caller_identity` and references no other security group, so a blank account ID costs it nothing. The bucket also gets `force_destroy`, since the webapp uploads into it and a destroy of a used bucket fails `BucketNotEmpty` without it. The e2e harness now asserts the bucket carries the tags Terraform set, which is what catches this regressing, and no longer aborts the remaining workbooks on a failure — `s3-webapp` failing says nothing about whether RDS works, and four nights of `rds-quickstart` coverage were lost that way. `STOP_ON_FAIL=1` restores the early abort. Every workbook's provider versions are pinned, so a run reproduces rather than picking up whatever the registry published that morning.
title, seoTitle, description, category, tags, resources
| title | seoTitle | description | category | tags | resources | |||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| S3-Backed Web App | Terraform S3-Backed Flask Web App on EC2 — Spinifex Docs | Deploy a Flask file-sharing app on EC2 backed by S3 (Predastore) with Terraform, using an IAM instance profile and short-lived STS credentials from IMDS. | Terraform Workbooks |
|
|
Terraform: S3-Backed Web App
Deploy a Flask file-sharing web application backed by S3 (Predastore) using Terraform on Spinifex.
Table of Contents
Overview
Deploy an EC2 instance running a Flask file-sharing web application backed by S3 (Predastore). Users can upload files through a web form and browse uploaded content — demonstrating Terraform managing both compute and object storage together.
This workbook uses the idiomatic AWS credential model: the instance is launched with an IAM instance profile and the app pulls short-lived STS credentials from IMDS (169.254.169.254) through boto3's default credential chain. No long-lived S3 keys are baked into the instance. The Terraform run still uses the operator's admin credentials (to create the bucket, role, profile, and instance), but the running instance authenticates to S3 with credentials it fetches at runtime and which expire in ~1 hour.
Architecture:
What you'll learn:
- Configuring the AWS provider with both Spinifex and Predastore endpoints
- Creating S3 buckets on Predastore via Terraform
- Defining an IAM role, least-privilege managed policy, and instance profile, and passing the role to an instance (
iam:PassRole) - How the instance obtains short-lived credentials from IMDS via boto3's default credential chain — no static keys on the instance
- Deploying a Python webapp with cloud-init that signs S3 requests with the STS session token
Prerequisites:
- Spinifex installed and running (see Installing Spinifex)
- Predastore running (S3 API on port 8443)
- An Ubuntu 26.04 AMI imported (see Setting Up Your Cluster)
- OpenTofu or Terraform installed
- The operator identity running the Terraform apply (
AWS_PROFILE=spinifex) must be allowed to manage IAM and pass the role:iam:CreateRole,iam:CreatePolicy,iam:AttachRolePolicy,iam:CreateInstanceProfile,iam:AddRoleToInstanceProfile, andiam:PassRoleon the new role. The bootstrap admin profile satisfies this. - The EC2 instance must be able to reach Predastore — use the host's br-wan IP, not localhost
Instructions
Step 1. Get the Template
Clone the Terraform examples from the Spinifex repository:
git clone --depth 1 --filter=blob:none --sparse https://github.com/mulgadc/spinifex.git spinifex-tf
cd spinifex-tf
git sparse-checkout set docs/terraform
cd docs/terraform/s3-webapp
Or create the files manually and paste the full configuration below.
Step 2. Create terraform.tfvars
Before deploying, create a terraform.tfvars with your Predastore credentials. The predastore_host must be reachable from inside the VPC — use the host's br-wan or LAN IP, not localhost.
Step 3. Create main.tf
Step 4. Deploy
The workbook defaults to t3.small (2 vCPU, 2 GiB). On clusters without that type registered, override with TF_VAR_instance_type — query what's available with aws ec2 describe-instance-types.
export AWS_PROFILE=spinifex
tofu init
tofu apply
Step 5. Test the Application
Note: EC2 instances can take 30+ seconds to boot after apply. If SSH or HTTP is unreachable, wait and retry.
Open the web_url output in your browser. You should see the file browser UI. Upload a file and verify it appears in the list.
# Verify via CLI
curl http://<public_ip>
# Check the S3 bucket directly
aws s3 ls s3://webapp-uploads/ --profile spinifex --endpoint-url https://localhost:8443
Clean Up
tofu destroy
Troubleshooting
Predastore Connection Refused from Instance
The EC2 instance cannot reach localhost on the host. Set predastore_host to the host's br-wan or LAN IP address:
predastore_host = "192.168.1.10:8443"
S3 Bucket Creation Fails
Verify Predastore is running and accessible:
curl -k https://localhost:8443/
aws s3 ls --profile spinifex --endpoint-url https://localhost:8443
ListTagsForResource ... no such host After the Bucket Is Created
The bucket was created and the failure is on the read-back of its tags. AWS provider 6.x reads bucket tags through S3 Control, whose endpoint carries the account ID in the hostname, so the provider dials https://000000000001.<your-endpoint> — a name that cannot resolve against an IP address.
This workbook's provider block already sets skip_requesting_account_id and skip_credentials_validation, which is what keeps the provider on S3 GetBucketTagging. If you copied the block into your own configuration, carry both across: skip_requesting_account_id on its own has no effect, because the credentials check supplies the account ID before the account lookup is skipped.
Flask App Not Starting
SSH into the instance and check the service:
ssh -i s3-webapp-demo.pem ubuntu@<public_ip>
sudo systemctl status s3-webapp
sudo journalctl -u s3-webapp --no-pager -n 50
Upload Fails with 403 AccessDenied
The instance reached S3 but was not authorized. The .env file deliberately holds no keys — credentials come from IMDS:
ssh -i s3-webapp-demo.pem ubuntu@<public_ip>
cat /opt/webapp/.env # S3_ENDPOINT / S3_BUCKET / S3_REGION only — no keys
Check that:
- the managed policy is attached to the role (
s3:ListBucketon the bucket,s3:GetObject/s3:PutObjecton its objects), and the policy's bucket name matchesbucket_name; - the assumed-role session's account matches the bucket owner's account — Predastore enforces bucket ownership. Both are created by the same operator identity, so they align by construction; a mismatch surfaces as
403.
NoCredentialsError / No Credentials
boto3 could not obtain credentials, which means the IMDS credential chain did not resolve. From the instance:
ssh -i s3-webapp-demo.pem ubuntu@<public_ip>
# IMDSv2: fetch a token, then the role name behind the instance profile
TOKEN=$(curl -sX PUT http://169.254.169.254/latest/api/token \
-H 'X-aws-ec2-metadata-token-ttl-seconds: 60')
curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
http://169.254.169.254/latest/meta-data/iam/security-credentials/
# Confirm the resolved identity — expect assumed-role/s3-webapp-role/<instance-id>
aws sts get-caller-identity --endpoint-url https://<predastore_host>
If the role name is empty, the instance profile was not attached — confirm iam_instance_profile on the instance and that iam:PassRole is allowed for the operator.
AMI Not Found
Ensure you have imported an Ubuntu 26.04 image:
aws ec2 describe-images --owners 000000000000 --profile spinifex