Skip to content
devopsbymuh_

Draft — the system is built, the drills are not all run yet. Every number here is marked pending until it is measured.

Private AI on AWS · No. 22
14 min readby Muhammad Rashid

What a self-hosted LLM really costs on AWS, and how I stopped paying for an idle GPU

One SageMaker GPU for a private vision model costs $4.17 an hour, which is about $3,044 a month if it never stops. In three days of testing the models did ten minutes of actual work. This is the on-demand GPU controller I built, the bug that switched the GPU off in the middle of someone's session, and the measured cost of an 11-minute cold start.

Amazon SageMakerml.g6e.xlargevLLMDjangoAmazon SQSPostgreSQLTerraform

The problem · Our private LLM costs thousands a month and sits idle most of the day.

My runbook said the GPU would cost about $2.40 an hour. The first AWS bill said $4.17. At that rate, one GPU left running costs $100 a day, or about $3,044 a month, and this one sits idle whenever nobody is uploading a document.

The GPU serves a KYC platform I built for a Pakistani client: two open-source vision models, Qwen3-VL-8B and PaddleOCR-VL, running in vLLM on one ml.g6e.xlarge (an NVIDIA L40S with 48 GB) in Singapore. The models run inside the client's own AWS account because the documents are national ID cards. The first case study covers how that deployment is secured. This one covers what it costs, and how the GPU went from a fixed monthly cost to one billed only for the hours it is used.

The bill, line by line

GPU price (ml.g6e.xlarge, SageMaker real-time)

2.73 billed hours cost $11.38 on 20–21 September. My planning document said $2.40.

$4.17/hour
The same GPU, never switched off

Arithmetic: $4.17 × 730 hours.

$3,044/month
Hours the GPU was up during testing

20–22 September. The 22nd is from the controller's start and stop log lines, because it was not on the bill yet.

~15 hours
Actual inference work in the same period

The sum of every model call's duration, 232 calls. The GPU was busy about 1.1% of the time it was up.

10 min 18 s
Everything else, for a full day

21 September: VPC interface endpoints $7.49, Fargate $4.30, Postgres $2.45, NAT $1.42, the rest under $1 each.

$18.03/day
AWS Cost Explorer, ap-southeast-1, and the endpoint's own logs.

That fourth row is the whole case for this piece. During a pilot, an applicant uploads a handful of documents, and each model call takes about two seconds. The GPU spends almost all of its paid time waiting.

Why I did not just let AWS autoscale it

The obvious answer is autoscaling. Three things ruled it out here:

  • This endpoint cannot go below one instance. It is a classic real-time endpoint, one model on a production variant, and that kind of endpoint keeps at least one instance running. SageMaker's asynchronous endpoints and inference components can scale to zero, and either is worth a look for a new build.
  • The quota is exactly one GPU. This account has an EC2 GPU quota of zero in every region, and a SageMaker quota of one ml.g6e.xlarge in Singapore. UpdateEndpoint does a blue/green swap that needs a second instance, so it always fails. Every change has to be delete, wait until it is gone, then create.
  • The platform needed more than scaling. It needed a way to switch verification off entirely, to run the GPU during Karachi business hours, to never stop in the middle of someone's document, and to tell applicants honestly when the GPU is starting.

So the application owns the endpoint's lifecycle, and the GPU mode is a setting in the admin panel.

Four modes, one decision function

  • Always on. Kept running. Results in seconds, and the $3,044-a-month bill.
  • Scheduled. Always on during set hours (by default 09:00 to 21:00 Karachi time, Monday to Friday), and on demand outside them.
  • On demand. Starts when an applicant enters an access code or uploads a document, and stops after 30 idle minutes. The first applicant after a quiet period waits for a cold start.
  • Off. Never started. New applicants see "Verification is paused".

Whether the GPU should be running is a pure function of the mode, the queue and the time since the last applicant activity. It calls no AWS API and touches no database, so every rule in it is covered by a unit test:

python
def wants_gpu(cfg, last_activity_at, backlog, now) -> bool:
    """Whether the GPU should be running right now under the chosen mode."""
    if cfg.mode == GpuMode.ALWAYS_ON:
        return True
    if cfg.mode == GpuMode.OFF:
        return False
    if cfg.mode == GpuMode.SCHEDULED and cfg.in_schedule(now):
        return True
    # On demand, and scheduled mode outside its hours.
    if backlog > 0:
        return True
    if last_activity_at is None:
        return False
    return now - last_activity_at < timedelta(minutes=cfg.idle_minutes)

A second pure function turns "should it run?" into at most one action per tick, with the rules that make it safe:

  • Never stop with work in flight. If a message is being processed, the stop waits for the next tick. A document is never cut off in the middle of inference.
  • Unknown queue depth counts as work. If SQS cannot be read, the controller assumes there is a backlog. Failing towards "on" costs money. Failing towards "off" loses an applicant.
  • A failed start is cleared, then backed off. A Failed endpoint is deleted to free the one-instance quota, and the controller waits ten minutes before trying again, because each attempt holds the GPU for up to the full startup timeout.
  • Creating and updating states are waited out. SageMaker rejects DeleteEndpoint while an endpoint is changing state, so the controller never tries.

Terraform builds it; the application turns it on

Terraform creates the model and the endpoint configuration. It does not own the endpoint itself. If it did, every terraform apply would recreate an endpoint the controller had deliberately stopped, at $4.17 an hour. A removed block took it out of the state without deleting the running endpoint:

hcl
# The ENDPOINT is deliberately not managed here. The worker's GPU
# controller creates and deletes it under the GPU mode, starting it from
# the newest endpoint configuration below.
#
# destroy = false drops it from state without deleting it, so adopting
# this change does not interrupt a running endpoint.
removed {
  from = aws_sagemaker_endpoint.this

  lifecycle {
    destroy = false
  }
}

The endpoint configuration's name is a hash of the settings that matter: the image, the instance type and the model. An earlier version used timestamp(), which changes on every plan, so every apply replaced the configuration and the endpoint behind it. That meant a full model reload, even for a no-op apply. With a content hash, the name changes only when the deployment actually does.

The worker's IAM role can create and delete exactly one endpoint name, using only configurations named after it. It cannot touch any other SageMaker resource in the account.

Many workers, one controller

Every worker task runs the controller loop every 15 seconds. Only one of them acts on each tick. The others only observe the endpoint, so that each one knows whether to take documents off the queue. A Postgres advisory lock picks the leader, which avoids adding a lock service just for this:

python
def _leader_lock() -> bool:
    """One controller acts per tick. Released when the transaction ends."""
    with connection.cursor() as cursor:
        cursor.execute("SELECT pg_try_advisory_xact_lock(%s)", [_LOCK_KEY])
        return bool(cursor.fetchone()[0])

What the applicant sees during an 11-minute cold start

Switching the GPU off is only acceptable if nothing is lost while it is off. Three rules make sure of that:

  • Documents wait in the queue; they do not fail. While the endpoint is not InService, workers leave messages on SQS instead of failing them against a GPU that is still loading. The page tells the applicant their documents are being held until verification starts.
  • Checks that need no GPU run straight away. A blurry photo, glare on the card, or a photocopy submitted instead of the original card are all detected on the CPU in the worker. The applicant hears about them immediately, not eleven minutes later.
  • Waiting documents are not deleted. The platform deletes document images within an hour of processing, for privacy. A document still waiting for the GPU is skipped by that sweep for up to 23 hours, and the bucket's one-day expiry stays the absolute limit.

Where the eleven minutes go

I measured two cold starts from the controller's CreateEndpoint call to SageMaker marking the endpoint InService: 11 min 16 s on 21 September and 11 min 24 s on 22 September. Both times were observed on a 15-second tick. The endpoint's own logs break the second one down:

Timeline of an 11 minute 24 second cold start: 5 minutes 23 seconds of AWS provisioning and weight sync, 1 minute 45 seconds loading the OCR model, 2 minutes 49 seconds loading the vision LLM, and 1 minute 10 seconds until SageMaker marks the endpoint InService.
22 September. Nearly half of the cold start happens before any of our code runs.
AWS provisions the instance and syncs the weights

From CreateEndpoint to the container's first log line. None of this is our code.

5 min 23 s
Our router starts and checks vLLM's flags

It reads vllm serve --help before passing optional flags.

17 s
OCR model (PaddleOCR-VL, 1.8 GB)

Weights 3.6 s, torch.compile 14.2 s, engine warm-up 29.8 s, plus process start.

1 min 45 s
Vision LLM (Qwen3-VL-8B FP8, 10.3 GB)

Weights 26.5 s, torch.compile 37.5 s, engine warm-up 60.8 s.

2 min 49 s
Health check passes, endpoint InService

Includes up to 15 s of the controller's own polling interval.

1 min 10 s
Cold start on 22 September 2026, from the endpoint's CloudWatch logs.

The two models load one after the other, not in parallel, even though that costs time. Each vLLM process measures free GPU memory when it starts, and two processes measuring at once can both claim the same memory. The health check returns 200 only when both are ready, so SageMaker never routes a request to an endpoint that is only half loaded.

The bug that switched the GPU off mid-session

The first version of the controller had a bug that only a deploy could trigger. A freshly started worker has no record of recent activity, so under on-demand mode its very first tick read a running GPU as idle, and stopped it.

The logs show exactly that. At 15:58:12 UTC on 21 September a new worker came up, saw the endpoint InService, and on the same tick logged gpu controller: stop (the GPU mode wants it off). Fifty-one seconds later the controller started it again, and the GPU was back in service at 16:10:19. That was eleven minutes of cold start that nobody should have had to wait through.

The fix treats a worker's own start time as activity, but only for a GPU that is already running or starting. A running GPU gets one full idle window after a deploy. A stopped GPU is not started by one:

python
activity = state.last_activity_at
if self.status in ("InService", "Creating") and (
    activity is None or activity < self._booted_at
):
    # A deploy restarts the worker. That is no reason to switch off
    # a GPU someone may be halfway through using: a running GPU gets
    # one full idle window from the restart. A stopped one is not
    # started by it.
    activity = self._booted_at
want = wants_gpu(cfg, activity, waiting + in_flight, now)

The next day the worker restarted six times between 04:17 and 12:02 UTC, and the endpoint stayed in service through every restart.

A second bug was caught before it happened in production. The hourly privacy sweep deletes any document image older than an hour. With the GPU off for longer than that, it would have deleted documents that were still waiting to be read, and the applicant would have had to upload them again. Documents waiting for the GPU are now exempt, up to the bucket's hard one-day limit.

The drills

Deploy during use (before the fix)

Failed
Action
A new worker started while the GPU was InService.
Expected
The GPU keeps running.
What happened
The new worker stopped it on its first tick, 20 seconds after it came up. It took 11 min 16 s to bring the GPU back.

Deploy during use (after the fix)

Held
Action
Six worker restarts on 22 September while the GPU was InService.
Expected
The GPU keeps running through every restart.
What happened
InService from 04:11:57 until the controller stopped it at 16:19:37, with no stop in between.

Cold start from nothing

Held
Action
The controller created the endpoint from zero, twice.
Expected
InService within the 15-minute startup timeout.
What happened
11 min 16 s and 11 min 24 s.

Upload while the GPU is off

Not yet run
Action
Upload a blurry photo and a sharp one with the GPU stopped.
Expected
The blurry photo is refused at once; the sharp one waits in the queue, the GPU starts, and it is read without a re-upload.

A start that fails

Not yet run
Action
Point the endpoint at a broken image tag.
Expected
The Failed endpoint is deleted to free the quota, the admin panel shows the error, and the next attempt waits ten minutes.

Stop requested mid-document

Not yet run
Action
Switch the mode to Off while a document is being read.
Expected
The stop waits until nothing is in flight. This is unit-tested, but not yet drilled live.
From the live development account. The first one failed, and it stays in.

What each mode costs

Always on

730 hours.

$3,044/month
Scheduled, weekdays 09:00–21:00

About 260 hours. On demand still covers evenings and weekends, at extra cost when used.

~$1,085/month
On demand

Every start also pays for 11 minutes of loading and up to 30 idle minutes before it stops.

$4.17/hr used
Off

The GPU line disappears. The private network does not: $7.49 a day in interface endpoints.

$0
Arithmetic from the measured $4.17/hour, not a measurement. The rest of the platform is about $540 a month on top.

One consequence surprised me. Once the GPU is on demand, it is no longer the biggest fixed cost. The VPC interface endpoints that keep the model off the internet cost $228 a month whether or not a single document is processed. In development, that is now the line to cut.

What I would do differently

  • Take the price from the bill on day one. Every estimate I made before the first invoice was 74% low.
  • Benchmark a smaller GPU. The two models together use about 12 GB of weights. An L4 with 24 GB might hold both, with less room for the KV cache. It is the next experiment, not a conclusion.
  • Look at scale-to-zero options first on a new build. Asynchronous endpoints and inference components can now scale to zero. With a one-instance quota and a need for business-hours scheduling, a custom controller was right here. It may not be for you.
  • Treat a deploy as an event the controller must survive. The mid-session stop was obvious in hindsight: any loop that makes decisions from in-memory state has to answer "what happens on the first tick after a restart?"

If your self-hosted model's bill is too high

  • Measure utilisation before you optimise anything. Add up the duration of every inference call, and divide it by the hours the GPU was up.
  • Decide what each hour of downtime costs you in waiting users, and pick a mode per environment: always on in production at peak, on demand in staging, off in development.
  • Keep documents in a queue, never in a failed state, while the model is loading, and run every CPU-only check first.
  • Let Terraform own the model and its configuration, and let the application own whether the endpoint exists.
  • Test what your controller does on its first tick after a deploy.

The application itself, covering how the two models read Pakistani ID cards and why the model never makes the approval decision, is written up on CodeWithMuh.

$ ./audit.sh --your-system

Want this done to your system?

Five days, fixed price, and a written report that tells you exactly what will break in production and in what order to fix it. If your system is the one in this article — inherited, AI-assisted, or working right up until it has customers — that is exactly what the audit is for.

Muhammad Rashid · Senior Backend & DevOps Engineer · LinkedIn · GitHub