Shipping the Same Spring Boot Service to AWS and GCP
"It's containerised, so it runs anywhere" is true of the container and almost nothing else. The image moves. The queue it reads from, the secret it needs at startup, the identity it authenticates as, and the way it opens a database connection are all cloud-specific, and all of them are where the deployment actually goes wrong.
Here is what I keep hitting when the same Spring Boot service has to run on both.
What is genuinely portable
One multi-stage build, one image, both clouds. This part really is boring, which is the point — spend your portability budget here and nowhere else.
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY .mvn .mvn
COPY mvnw pom.xml ./
RUN ./mvnw -B dependency:go-offline # cached unless pom.xml changes
COPY src ./src
RUN ./mvnw -B clean package -DskipTests
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
COPY --from=build /app/target/*.jar app.jar
USER app
EXPOSE 8080
ENTRYPOINT ["java","-XX:MaxRAMPercentage=75","-jar","app.jar"] OutOfMemoryError. You get a restart loop with no stack trace. Modern JVMs are container-aware, but the percentage is still worth setting explicitly.Compute: Fargate against Cloud Run
| AWS — ECS on Fargate | GCP — Cloud Run | |
|---|---|---|
| Model | Long-running tasks | Request-driven, scales to zero |
| Idle cost | You pay for running tasks | Nothing at zero traffic |
| Cold starts | Not really a factor | Real, and JVMs are not fast to start |
| Background work | Fine — the task is always up | Needs care; CPU is throttled outside a request |
That last row is the one that bites. Cloud Run's default is to throttle CPU when no request is in flight, so @Scheduled jobs, Kafka consumers and async workers either crawl or stop. Either set the service to always allocate CPU, or — better — move the background work out to a job that runs on its own schedule.
Cold starts are the other one. A Spring Boot service taking several seconds to come up is invisible on Fargate and painful on a scale-to-zero platform. Setting a minimum instance count fixes it and gives back the cost saving that made Cloud Run attractive, so decide which you actually wanted.
Lambda against Cloud Run
These get compared constantly, and they are not the same shape. Lambda is a function with a managed runtime around it; Cloud Run is your container, scaled to zero. The GCP equivalent of Lambda is Cloud Functions — but since Cloud Run is what people actually reach for, the comparison worth having is this one.
| AWS Lambda | GCP Cloud Run | |
|---|---|---|
| Unit | A handler function | A container listening on a port |
| Max duration | 15 minutes, hard | Configurable, and far longer |
| Concurrency | One request per instance | Many per instance — the JVM gets used properly |
| Portability | Handler ties you to the platform | Same image runs anywhere |
The concurrency row is the one that decides it for Spring Boot. Lambda gives each instance a single request at a time, so you pay the JVM's memory footprint per concurrent request and get none of the throughput a warm JVM is good at. Cloud Run sends many requests to one container, which is the model the framework was built for.
My rule: Lambda for genuinely event-shaped work that finishes quickly — an S3 upload trigger, a scheduled cleanup, a webhook receiver. Cloud Run or Fargate for anything that is a service with an HTTP API, which a Spring Boot application almost always is.
Messaging: SQS against Pub/Sub
Both are at-least-once. Both hand you a message with a deadline and expect an acknowledgement. The vocabulary differs and the failure modes rhyme.
| Concept | SQS | Pub/Sub |
|---|---|---|
| Time to process | Visibility timeout | Ack deadline |
| Give up after N tries | Redrive policy → DLQ | Dead-letter topic |
| Ordering | FIFO queues only | Ordering keys, per key |
| Fan-out | SNS in front of queues | Built in — topic, many subscriptions |
// static import: GcpPubSubHeaders.ORIGINAL_MESSAGE
@Component
@RequiredArgsConstructor
class OrderEvents {
private final OrderService orders;
private final ProcessedEvents processed;
@SqsListener("order-events") // AWS
void onSqs(OrderEvent event, @Header("MessageId") String id) {
handle(event, id);
}
@ServiceActivator(inputChannel = "orderEvents") // GCP
void onPubSub(
OrderEvent event,
@Header(ORIGINAL_MESSAGE) BasicAcknowledgeablePubsubMessage msg) {
handle(event, msg.getPubsubMessage().getMessageId());
msg.ack();
}
/** The part worth keeping identical. */
private void handle(OrderEvent event, String id) {
if (!processed.claim(id)) return; // both deliver at least once
orders.apply(event);
}
}Note what is shared and what is not. The transport bindings differ because they must; the business logic is one method, and it is idempotent because both platforms redeliver. That is the abstraction worth having — a shared handler — rather than a MessageQueue interface with two implementations that leaks the differences anyway.
Object storage: S3 against Cloud Storage
This is the closest pairing of the lot. Both are buckets of immutable objects with lifecycle rules, storage tiers and event notifications, and both are strongly consistent — S3 has been read-after-write consistent since 2020, so the "eventual consistency" advice still floating around is out of date.
| Concept | S3 | Cloud Storage |
|---|---|---|
| Namespace | Bucket names global per partition | Bucket names globally unique |
| Temporary access | Presigned URL | Signed URL |
| Cool tiers | Standard-IA, Glacier | Nearline, Coldline, Archive |
| Change events | S3 notifications → SQS / SNS / Lambda | Notifications → Pub/Sub |
The one that matters in application code is temporary access. Do not stream a file through your service to hand it to a browser — issue a time-limited URL and let the client talk to storage directly. Your container stops being a proxy for bytes it has no opinion about.
// AWS — presigned GET, expires in 15 minutes
PresignedGetObjectRequest presigned = presigner.presignGetObject(r -> r
.signatureDuration(Duration.ofMinutes(15))
.getObjectRequest(g -> g.bucket("invoices").key(key)));
URL awsUrl = presigned.url();
// GCP — same idea, V4 signing
URL gcpUrl = storage.signUrl(
BlobInfo.newBuilder("invoices", key).build(),
15, TimeUnit.MINUTES,
Storage.SignUrlOption.withV4Signature());The event wiring is worth noting too, because it is where the two diverge in shape. S3 notifications fan out to SQS, SNS or Lambda directly; Cloud Storage sends everything to Pub/Sub and you subscribe from there. Same capability, one indirection apart — and it means the "upload triggers processing" pattern is wired differently even though the handler is identical.
Secrets, and the mistake everyone makes first
AWS calls it Secrets Manager. GCP calls it Secret Manager. The near-identical names are the least of it — what matters is that neither should end up in an environment variable you pasted by hand.
spring:
config:
import: aws-secretsmanager:/prod/order-service
datasource:
url: ${db-url}
username: ${db-user}
password: ${db-password}spring:
config:
import: sm://
datasource:
url: ${sm://db-url}
username: ${sm://db-user}
password: ${sm://db-password}Both starters resolve secrets at startup through spring.config.import, so the properties look the same to the rest of the application and only the profile differs. Rotate a secret and you restart the service — which is fine, and much better than a credential living in a deployment manifest in git.
Identity: stop shipping keys
This is the difference that matters most for security, and the one most often skipped because static credentials are quicker.
- AWS: give the ECS task an IAM task role. The SDK picks up temporary credentials from the container credentials endpoint through the default provider chain. No key, no secret, nothing to leak.
- GCP: run the service as a dedicated service account. Application Default Credentials resolve automatically on Cloud Run. No JSON key file.
.env files, and ends up committed eventually. Both platforms give the workload an identity for free. Use it.Databases: the connection is the difference
RDS and Cloud SQL both hand you managed PostgreSQL, and the SQL is the same. Connecting is not.
Cloud SQL expects the Auth Proxy or the JDBC socket factory, which authenticates with your service account and encrypts the connection — a dependency and some JDBC URL properties, not a network rule. RDS is reached over the VPC, so access is a security-group problem instead.
spring:
datasource:
url: jdbc:postgresql:///orders
hikari:
data-source-properties:
socketFactory: com.google.cloud.sql.postgres.SocketFactory
cloudSqlInstance: my-project:asia-south1:orders-dbOne thing that catches people on both: connection pools multiply by instance count. A pool of 10 looks modest until autoscaling gives you 40 containers and the database refuses connection 401. Size the pool against the instance ceiling, not against one container — and on a scale-to-zero platform, keep it small, because instances are cheap and connections are not.
What I would actually abstract
After doing this a few times, the line sits here:
| Abstract it | Leave it cloud-specific |
|---|---|
| The business handler both listeners call | The listener annotations |
| Property names the app reads | How those properties get populated |
| Health and readiness endpoints | How the platform probes them |
| The container image | The deployment manifest |
Spring profiles do most of the work. One application.yml for everything shared, an application-aws.yml and an application-gcp.yml for the wiring, and the profile set by the platform.