Four Compute Models, One Artifact¶
I deployed the same Spring Boot JAR on EC2 with an Auto Scaling Group, on ECS Fargate, on a bare metal Kubernetes cluster, and on EKS. The artifact never changed. Everything that changed between the four is therefore not a property of the application, which makes it a fairly honest way to find out what each layer actually costs to operate.
Try It Yourself
Every manifest, launch template and overlay is public in the CD repository, one directory per compute model.
Holding Everything Else Still¶
The comparison is only worth anything if the compute layer is the sole variable, so three things stayed fixed across all four deployments.
The network. One VPC, two public and two private subnets across two availability zones, one NAT gateway, and the same three security groups chained sg-alb to sg-app to sg-rds. Each tier accepts traffic only from the tier directly in front of it.
The database. One RDS MySQL 8.4 instance in a private subnet, reachable only from the compute tier.
The domain and certificate. A Route 53 hosted zone and an ACM certificate, both created during the first deployment and reused unchanged by the other three. That reuse is not a shortcut. It is the point: DNS and TLS are infrastructure the application should never notice, and if changing the compute model forces you to reissue a certificate then something is wired to the wrong layer.
The application did not learn anything about any of this. Database connectivity moved from localhost on bare metal, to a Docker Compose DNS name locally, to an RDS endpoint in the cloud, through environment variables only. No recompilation, no profile switch, no source change.
Model 1: EC2 with an Auto Scaling Group¶
The baseline, and deliberately containerless.
The JAR lives in S3. Instances pull it at boot through an IAM instance profile and run it under systemd, with all of that expressed as a User Data script inside a Launch Template. No custom AMI is baked, which means a new build is an S3 upload and an instance refresh rather than an image pipeline.
The Auto Scaling Group spans both private subnets, minimum two, desired two, maximum four. The ALB sits in the public subnets and terminates TLS.
What you own at this layer is everything between "an instance exists" and "the application is listening": fetching the artifact, placing it, writing the unit file, starting it, and making the instance's health legible to the load balancer. It is the most code, and it is also the layer where every step is visible and debuggable.
One deliberate omission in the Launch Template is worth calling out. Subnet and availability zone are not set there. Placement belongs to the Auto Scaling Group, and pinning it in the template would defeat multi-AZ distribution while looking perfectly reasonable in a diff.
Model 2: ECS Fargate¶
The same application, containerised, with the instances abstracted away.
The differences are mechanical and they map almost one to one:
| Concern | EC2 with ASG | ECS Fargate |
|---|---|---|
| Compute unit | EC2 instance | Task |
| Scaling control | Auto Scaling Group | Service desired count |
| Bootstrap | User Data plus systemd | Task Definition plus image |
| Artifact store | S3 (JAR) | ECR (image) |
| Identity | EC2 instance profile | Task execution role |
| Target group type | instance | ip |
| Private IPs consumed | One per instance | One per task |
| Shell access | Bastion to instance | None |
The row that actually bites is targetType. On EC2 the load balancer forwards to instance IDs. On Fargate every task gets its own elastic network interface with its own private IP, so the target group has to register IPs instead. Leave it at instance and the target group simply never becomes healthy, with no error explaining why.
The bootstrap work from model 1 does not get simpler here. It disappears. There is no User Data, no systemd unit, no artifact fetch, because the image already contains all of it and the platform starts the container. What replaces it is a task definition, and observability arrives differently too: with no SSH, the awslogs driver streaming to CloudWatch stops being a nice-to-have and becomes the only way to see anything.
The two application-layer fixes carried over untouched
Both failures from the EC2 deployment, a health endpoint returning 302 and an HTTPS login redirect loop, reappeared identically on Fargate, because neither was ever a property of the compute layer. Same two fixes, same order, nothing rediscovered. That is the clearest evidence that the variable really was isolated.
Models 3 and 4: Kubernetes, Twice¶
Bare metal and EKS share one Kustomize base and diverge through two overlays. The base holds the namespace, ConfigMap, Secret, the MySQL PVC, Deployment and Service, and the application Deployment and Service.
A detail worth being accurate about, because my own runbook gets it wrong: MySQL in the base is a Deployment with a ClusterIP Service, not a StatefulSet with a headless Service. For a single replica bound to one PVC that works, but StatefulSet is the correct shape for a database and this is on the list to change rather than something to defend.
What the base deliberately leaves out¶
Three omissions do most of the work in this design.
No storageClassName on the PVC. Hardcoding gp3 in the base is the fastest way to build a manifest that only runs in one place, because a bare metal cluster has never heard of it. Each overlay patches it in: local-path on bare metal, gp3 on EKS.
No HorizontalPodAutoscaler. An HPA needs the Metrics Server. On EKS that is expected; on a local cluster it usually is not installed, and an HPA without it does not sit idle, it reports errors forever. So the HPA exists only in the EKS overlay, scaling between two and five replicas against 50 percent CPU and 70 percent memory.
No HTTPRoute. This is the one I would not have predicted. Routing genuinely differs between the two platforms rather than differing cosmetically. On bare metal with NGINX Gateway Fabric, the HTTP to HTTPS redirect has to be written into the HTTPRoute itself. On EKS the AWS Load Balancer Controller handles termination and redirection at the Gateway. Sharing one HTTPRoute would mean writing a redirect that is required in one environment and actively wrong in the other.
Where the two overlays actually part¶
| Bare metal | EKS | |
|---|---|---|
| Storage | local-path HostPath | EBS gp3 |
| Gateway implementation | NGINX Gateway Fabric | AWS Load Balancer Controller |
| TLS | cert-manager, HTTP-01, in cluster | ACM certificate at the ALB |
| Database | In-cluster MySQL | ConfigMap patched to RDS |
| Autoscaling | None | HPA, 2 to 5 |
Getting TLS working on the bare metal side turned out to be a considerably deeper problem than the EKS side, where an ACM ARN in a patch is the entire story.
Every environment-specific value, the RDS endpoint and the certificate ARN, lives in kustomization.yaml rather than in the resource files. Individual manifests stay untouched, so a reader can diff two overlays and see the complete set of differences in one file each.
What Each Layer Costs¶
The useful output is not a ranking. It is a clearer sense of where the work goes.
EC2 gives you the most control and charges you for all of it. You write the bootstrap, so you can make it do anything, and you also own every way it can fail. Debugging is easy because everything is a file on a machine you can SSH into.
Fargate deletes the bootstrap and takes the machine with it. Less to write and less to get wrong, but when something misbehaves there is no instance to inspect, and anything you did not put in a log is gone.
Bare metal Kubernetes gives you the orchestration model without the cloud integrations. Every convenience that EKS gets from AWS, load balancer provisioning, storage classes, certificate management, you install and operate yourself. It is the most educational of the four for exactly that reason.
EKS is the orchestration model with the integrations restored. The overlay is short because the platform supplies what bare metal made you build.
One Thing That Is Not Finished¶
The CI pipeline writes an immutable tag into image.env in the deployment repository on every build. All three Kustomize files hardcode newTag: latest. Nothing reads image.env.
So the tag is recorded and not consumed, and what actually deploys is whatever latest currently points at. There is no ArgoCD Application for this project either; it is applied with kubectl apply -k, which the base annotations state honestly enough (gitops/managed-by: kustomize-cli).
The fix is small: have the overlay read the tag, or have CI patch it. I am writing it down rather than quietly correcting it because it is the exact failure mode this series keeps finding, a value that is produced correctly and never consumed, and it is more useful as an example than as a silent commit.
Source¶
- All four deployments, one directory each
- Deployment runbook, one phase per model
- The application, Spring Boot 3.4 on Java 21
Series: One Application, Four Ways (Part 1 of 4)