Four Layers Between You and a Surprise Bill: The AWS Side of the Studio (Part 3 of 3)

In Part 1 we got the studio running, and in Part 2 we opened it up. This last part is about the piece most of us are quietly nervous about when we hear “GPU in the cloud”: not the setup, the bill.
A GPU server costs about $0.80 an hour. That sounds like nothing, and it is, for an hour. Left running for a month because you closed the laptop and forgot, it’s around $600. The models are interesting; the meter is what decides whether you’d ever run this again. So most of the decisions in this part are about the meter.
By the end of this part you’ll know what deploy.sh builds in your account and why there’s so little of it,
why the scripts run as a role that can barely do anything, how the server stops itself, when to stop it and when
to remove it, what it really costs, and what broke the first time I ran it for real.
What one command builds#
The honest answer is: very little. That’s on purpose. Every resource is something to pay for, secure, and eventually remember to delete.
flowchart LR
subgraph laptop["Your computer"]
tb["Toolbox container<br/>Terraform · AWS CLI"]
end
subgraph aws["Your AWS account"]
tf["Created by Terraform:<br/>GPU server · private bucket<br/>instance role · log group<br/>stop alarm · budget"]
ssm["Systems Manager<br/>(no SSH, no open ports)"]
end
tb -- "1. plan → you confirm → apply" --> tf
tb -- "2. code bundle → bucket" --> tf
tb -- "3. 'install this release'" --> ssm
ssm -- "runs on the server" --> tfTerraform creates one GPU server (a g6.xlarge with an NVIDIA L4), one private and encrypted bucket, the role the server runs as, a log group, an alarm and a budget. No VPC, no subnets, no load balancer: it uses the default VPC every account already has, because building a network would mean a NAT gateway at about $32 a month, for a server that only ever makes outbound calls.
Then Terraform stops. It creates an empty server and nothing more. Installing the app is a separate step:
deploy.sh packages the code, puts it in the bucket, and asks AWS Systems Manager to run the install on the
server. Keeping the two apart means a code change never touches the infrastructure, and an infrastructure change
never reinstalls the app. It also means the server needs no SSH key and no open port, ever.
Every resource carries one tag, project=voiceover-studio. It looks like housekeeping. It turns out to be the
thread the whole teardown hangs on, as we’ll see further down.
Read the code: The infrastructure goes through all eight Terraform files and why each setting is there, including the three lifecycle rules that stop a routine change from replacing the server.
A role that can barely do anything#
The scripts don’t run with your admin login. They run as a deploy role, VoiceoverStudioDeployer, that the
one-time account setup creates. You sign in as an everyday user that can do exactly one thing: assume that role,
with MFA, for an hour at a time. No access keys exist anywhere, on your laptop or in the repo.
And the role itself is fenced in. Its permissions only cover resources with this project’s names and tag, and a separate policy of explicit denies sits on top, which in AWS win over any permission:
- one region, and four instance types (g6, g5 and g4dn
.xlarge, plus a small CPU type); - images owned by Amazon only, so no paid Marketplace images;
- gp3 disks up to 200 GB, with no paid extra speed;
- no inbound ports, no NAT gateway, no fixed IP addresses, no reserved capacity;
- and no way to remove these rules from itself.
The server gets a role too, so the app can read its bucket and write its logs. That role carries a permissions boundary: a ceiling on what a role can ever be allowed to do, and the deploy role can’t create a role without it. Even if someone attached a broader policy later, the server still couldn’t reach past its own bucket, its own secrets and its own logs.
If you ask me, this is the part of cloud security that’s easy to skip on a side project and the part that matters most. Most surprise bills and most breaches aren’t clever. They’re a mistake that the account allowed. I’d much rather make the expensive mistakes impossible than rely on myself to be careful at the end of a long evening.
Your existing AWS account is fine for all of this. The rules are attached to the project’s role, not the account, so nothing else you run is affected.
Secrets never sit in a file. The scripts hand the role’s temporary credentials to the toolbox container by name, so they never show up in a process list. The settings file is read as data, never executed. Terraform’s state file is plain text, so no secret ever passes through Terraform. The optional Hugging Face token lives in Systems Manager Parameter Store and is read by the server at run time.
Four layers between you and a surprise bill#
Here’s the meter problem again: a GPU that nobody’s using still costs $0.80 an hour. The studio puts four separate layers in the way, from fastest to slowest:
| Layer | What it does | How fast |
|---|---|---|
| 1. The idle timer | The server asks the app every five minutes when someone last did something. After 30 minutes of nothing, it shuts itself down. | 30–35 minutes |
| 2. A CloudWatch alarm | Watches the server from outside. An hour of near-zero CPU, and AWS stops it. | About an hour |
| 3. Budget emails | A project budget ($30 a month by default) emails you at 80 % of actual spend and when the forecast passes 100 %. | Up to a day: billing data lags |
| 4. Account rules | The GPU quota of 4 vCPUs allows exactly one GPU server. The guardrails rule out anything bigger. | Always on |
Each layer covers a way the one before it can fail. A few details that take more thought than you’d expect:
- “Activity” means a real action. An open browser tab keeps asking the server for updates. If that counted, a tab you forgot on Friday would keep the GPU running all weekend. So only actions count: loading the page, saving a voice, writing a script, generating. A render in progress always counts.
- The alarm has to be able to fire twice. CloudWatch alarms act only when they change state. A stopped server sends no data, so the alarm is told to treat missing data as “fine”, which resets it while the server is off, and lets it fire again after the next start. Without that one line it would work exactly once.
Shutting down means “stop”, not “delete”. The server is set up so that shutting down from the inside stops it, keeping the disk. Your voices, models and installed app are all there the next time.
Layer 4 assumes a quota of exactly 4. The guardrails limit which instance types the role can start, not how many: IAM has no way to count servers. In an existing account with a higher GPU quota, the quota no longer stops a second one. The scripts only ever create one, so it would take a mistake outside them, but this layer is weaker there.
The budget is the last line, not the first, because it’s the slowest. By the time a budget email arrives, the money is already spent. The idle timer is the one that actually saves you; the others are there for the day it doesn’t.
Parked, not returned: stop or destroy?#
The way I think about the two commands at the end of a session is a car. stop.sh is parking it: the engine
is off, nothing’s burning fuel, but you’re still paying for the parking space. destroy.sh is returning it:
nothing left to pay for, and nothing left to come back to.
stop.sh (parked) | destroy.sh (returned) | |
|---|---|---|
| Server | Stopped | Deleted |
| What you still pay | The disk: about $12 a month for 150 GB | Nothing |
| Your voices and outputs | Kept in your bucket | Offered as a download first, then deleted |
| Coming back | start.sh, then the models load again | deploy.sh, about 25 minutes |
If you’ll use it again in the next few weeks, park it. If you were trying it out, or won’t touch it for a couple of months, return it: $12 a month is a lot to pay for a parking space you don’t use, and redeploying costs about 35 cents.
destroy.sh doesn’t stop at deleting. It ends by asking AWS for anything still tagged with the project, and fails
loudly if something is left. A forgotten resource is how a cloud experiment turns into a bill months later, so I
wanted the proof to be part of the tool.
That check had its own bug, and it’s worth telling. The first time I ran destroy.sh, it
reported three servers and a disk still there. All of them were gone. AWS’s tagging index, which the check asked,
lags behind deletions, and it still listed servers deleted hours earlier. A check that cries wolf is nearly as
bad as no check at all, because the next time it’s right, you won’t believe it. Now each listed resource is
confirmed with its own service, and only a clear “not found” counts as gone. Anything else, such as an expired
login, counts as still there. It should never hide a real leftover, even if that means a false alarm.
Read the code: Deploying and running it
follows deploy.sh from your laptop to a running app, how connect.sh reaches a server with no open ports, the
idle timer, and how destroy.sh proves nothing is left. Every AWS service the project touches, with its cost, is
listed in AWS services used.
What it really costs#
Measured in November 2026, us-east-1, on a g6.xlarge. Part 1 had the headline numbers; these are the scenarios I’d actually plan around:
| How you use it | Roughly |
|---|---|
Trying it once: set up, use it for two hours, destroy.sh | $2 to $4 in total |
| Occasional: about 10 GPU hours a month, parked in between | $20 to $24 a month ($8–10 GPU, $12 disk) |
| Left running by mistake, all month | Around $600, if all four layers failed |
| A finished one-minute voiceover | About 1 cent of GPU time |
| First setup, start to finish | 26 minutes, about 35 cents |
Two things stand out. First, in the occasional scenario, the parked disk costs more than the GPU. The expensive-sounding part is the cheap one if you stop it. Second, the voiceovers themselves are a rounding error. What you pay for is the time the server is on. Everything in the previous two sections is about that.
All of this is us-east-1, which is among the cheapest regions for this server. The tunnel’s latency doesn’t matter for a tool like this, so pick the region on price, not distance. In November 2026 a g6.xlarge cost $0.805 an hour in us-east-1, us-east-2 and us-west-2, $0.966 in Mumbai and about $1.01 in Frankfurt. Choose it once, before the account setup: the guardrails allow exactly one region.
What’s deliberately not on the bill: a NAT gateway, a load balancer, a container registry, a managed key service, SageMaker. Each would be the “proper” choice for a team product. For one person with one GPU, each is a monthly cost for something the project doesn’t need.
What the scanners found#
Before making the repo public, I added a security workflow that runs on every push and once a week: CodeQL for the code, OSV-Scanner for every dependency version, Trivy for the Terraform and Docker files, and gitleaks for secrets across the whole history.
The first run found real things:
- CodeQL found two real issues, both fixed the same day. The upload handler trusted the file path it was given; it now accepts only files from Gradio’s own upload folder, and at most 20 MB. And the CI workflows used actions by version tag, which their owners can move; they’re now pinned to exact commits.
- The dependency scan flagged 47 advisories, in three packages: pillow, transformers and accelerate. I can’t upgrade them yet. The voice model’s library pins an older transformers, which holds back a chain of others, the same chain that held back the Brave fix in Part 2.
- Trivy flagged four infrastructure choices: outbound traffic to any address, no bucket versioning, S3’s own encryption keys instead of a dedicated key, and a toolbox container that runs as root. All four are deliberate, and each one is a trade-off this part has already argued for.
For the ones I accepted, the reason is written down next to the finding: pillow’s 41 advisories are all about reading image files, and the studio never opens an image. Each accepted dependency covers one exact version and expires, so it comes back for review instead of being ignored forever.
That’s my take on scanners in general. A green badge doesn’t mean the code is safe. A list of known findings, each with a reason and a date to look again, is what tells you somebody actually thought about it.
Everything the first test run broke#
I followed the README on a fresh account, the way a reader would. The first deploy succeeded on the fifth attempt. Here’s what it took:
Gotchas from the first real run
- Windows has three shells, and two of them can’t run this. PowerShell can’t run
.shfiles. A plainbashmay be WSL, which can’t see Docker Desktop or your AWS login. Use Git Bash. - Git Bash rewrites paths. It turned the container’s
/workintoC:/Program Files/Git/work. The scripts now pass Windows paths to Docker on purpose. /bin/shon Ubuntu isn’t bash. Systems Manager runs commands withsh, which on Ubuntu isdash, and the install failed on its first line. Commands now run through bash explicitly.- Long commands time out. A 25-minute install inside one remote command hit its time limit. The install now
runs as a background service on the server, and
deploy.shfollows its log. - 100 GB wasn’t enough. Unpacking the model server’s image needs the compressed and unpacked copy at once. The error was cut to 200 characters, which hid the reason. Now: 150 GB, a disk check before downloading, and errors shown in full.
- “No capacity” is a normal answer. AWS sometimes has no spare GPU in a zone. The scripts retry, then list other zones. The quota is per region, so moving zones costs nothing.
- The safety net works on you too. After a failed install, the idle timer stopped the server five minutes later. Correct, and surprising.
- Indexes lag. The
destroy.shfalse alarm above.
Every one of these sits on a boundary between two systems: Windows and Docker, Ubuntu and Systems Manager, my scripts and AWS’s capacity or AWS’s index. None of them was in the code itself, and none of them would have shown up in a unit test.
How this was built#
I built this with Claude Code as a pair programmer, and I’d rather say so than leave you to guess. I designed the system and the guardrails, made the decisions and ran every test on a real AWS account; Claude Code wrote much of the code and the documentation under my direction and review. The repo’s commits show which ones it helped with.
In What would it take for you to never read your code again?
I said ownership stays with the engineer, and that a harness, a testing layer that checks the software does what
was asked, is what would let us trust generated code. This project has one: over 90 tests, linters, Terraform
validation and, now, four security scanners. The list above is what got past it. None of those failures was a
logic bug. Each came from something nobody had written down: what sh means on Ubuntu, how long a remote command
may run, how fresh an AWS index is.
And one more got past it while I was writing this series: the watermark bug from Part 2. Every test read the file straight back, so every test passed. It took exporting a real video to find that the mark didn’t survive.
That’s the spec problem from that article, showing up in a small project. The harness can only check what someone thought to write down. Running it for real, in a real account, is still how you find the rest.
Where this leaves us#
Three parts in, here’s what I set out to find: can a voice tool be useful and yours? For one person or a small team, I think yes. Ten seconds of your voice, a model you’re allowed to use, a consent check and a watermark in the core, one GPU that switches itself off, and an account that makes the expensive mistakes impossible. A cent a minute, plus the time to set it up once.
It’s not finished, and it doesn’t have to be. The models will be replaced; the parts around them shouldn’t need to be.
As always, this is my view and my setup. If you’d have made different calls on the AWS side, or you run it and something breaks on a setup I haven’t tried, I’d be glad to hear about it in the comments.
Park it when you’re done. Return it when you’re sure.
The code is MIT-licensed at github.com/WornKeys/voiceover-studio.


Discussion
Comments are powered by GitHub Discussions. Sign in with GitHub to join in.