Deploying SecureStack AI to Real AWS Infrastructure

Published on
5 mins read
Written by

It's easy to write "deployed to AWS" on a portfolio project and mean a screenshot of a Terraform plan that never actually ran. For SecureStack AI I wanted the opposite: a real, temporary deployment on ECS and RDS, torn down afterward, with the account-level decisions treated as seriously as the application code. Here's what that actually involved.

The first surprise: the plan I wrote down wasn't the plan I used

The original hosting choice was AWS App Runner. It's closed to new customer accounts now, so the deployment runbook pivoted to Amazon ECS Express Mode instead, which AWS points new users toward as the replacement. The tradeoff turned out fine: one CLI call provisions the service, load balancer, security groups, and logging together, and one CLI call tears it all down. Writing the runbook before touching the console meant discovering that dead end on paper instead of half-deployed.

Five minutes of insurance before anything else exists

Before creating a single resource, I set a monthly cost budget in AWS Billing: $10, with an alert at 80% of that, sent to email, both actual and forecasted spend. The whole exercise was projected to cost under a dollar in infrastructure. The budget isn't there because I expected to go over. It's there because "I'll remember to tear this down" is not a control, and a five-minute setup step is cheap insurance against a forgotten resource quietly running up a bill.

Root stays untouched, a scoped IAM user does the work

Root has unrestricted access to the account, including billing, so it never gets an access key, and no CLI command in this project ever runs as root. Instead, I created a single IAM user with a policy scoped to exactly what this deployment needs: ECR, ECS, RDS, IAM role creation for the app's own roles, and nothing else. If that access key ever leaked, the blast radius is bounded to this one deployment's permissions, not the whole account. That's the same least-privilege posture the application enforces on the OpenAI tool calls it makes internally, just applied one layer down, to the infrastructure that runs the app instead of the app itself.

The permissions-boundary bug that only shows up on paper

The deployment uses two IAM roles: an ECS execution role (pulls images, writes logs, reads secrets) and a separate infra role (the one Express Mode uses to provision the ALB, security groups, and autoscaling). Each gets its own permissions boundary, and that split isn't cosmetic. A security review of the first draft found that a single shared boundary covering both roles' ceilings would let the execution role inherit the infra role's much broader ceiling, ELB, EC2, ACM, autoscaling, CloudWatch, service-linked-role actions, if that execution role were ever supplied as an ECS task's taskRoleArn instead of its intended executionRoleArn. The reason is subtle: iam:PassedToService conditions scope PassRole by which service a role gets passed to, not by which field in a task definition it gets placed in. A correctly scoped PassRole condition doesn't protect against that confusion by itself. Two separate boundary objects do: with each role's ceiling defined independently, there's no path for one role's permissions to ever expand into the other's, regardless of which field a future task definition puts it in.

Two deployment problems that only show up once you actually deploy

Both of these only surfaced against real infrastructure, not in the runbook. Running the database migration as a one-off Fargate task against RDS failed with SELF_SIGNED_CERT_IN_CHAIN, traced to pg-connection-string treating sslmode=require as an alias for full certificate-chain validation against a CA Node doesn't trust by default. And a brand-new AWS account needs iam:CreateServiceLinkedRole triggered explicitly the first time ECS, ELB, or Application Auto Scaling gets used, something granting the permission in a policy alone doesn't take care of. I wrote up both fixes in more detail in the SecureStack AI post about the application itself. This one's about the account and IAM design around the deployment, not the app.

The compensating control for staying up longer than a demo usually does

The deployment isn't torn down same-day. It's staying live for a few days so it can be explored and recorded for a walkthrough before teardown. That changes the risk calculus: a long-lived public URL is a target for scraping and unexpected OpenAI API costs in a way a same-day teardown isn't. The compensating control is a security-group rule restricting the load balancer to a single /32 IP, the operator's own address at setup time, with every prior 0.0.0.0/0 rule revoked. It's a small thing, but it's the difference between "temporary" meaning something and "temporary" being a hope.

What it actually costs to do this for real

Combined Fargate and load-balancer charges run about $0.05/hour, and the RDS instance is small enough to likely sit inside the new-account free tier. Kept up for several days instead of torn down same-day, the realistic total lands around $3 to $5 in infrastructure, not counting OpenAI API usage from actually using the app during that window. None of that is a large number. It's just a number I wanted to actually know, instead of assuming, before calling this deployment done.