← All posts

Shipping the Same Spring Boot Service to AWS and GCP

22 Jul 202610 min read
AWSGCPCloudSpring Boot

"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.

Dockerfile
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"]
Set MaxRAMPercentageThe JVM sizes its heap from what it believes the machine has. In a container with a hard memory limit, the default heuristic can leave too little headroom for everything outside the heap, and the platform kills the container rather than the JVM throwing 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 FargateGCP — Cloud Run
ModelLong-running tasksRequest-driven, scales to zero
Idle costYou pay for running tasksNothing at zero traffic
Cold startsNot really a factorReal, and JVMs are not fast to start
Background workFine — the task is always upNeeds 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 LambdaGCP Cloud Run
UnitA handler functionA container listening on a port
Max duration15 minutes, hardConfigurable, and far longer
ConcurrencyOne request per instanceMany per instance — the JVM gets used properly
PortabilityHandler ties you to the platformSame 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.

If it has to be LambdaJava cold starts on Lambda are the well-known complaint — classloading and context initialisation happen on the request that triggered the scale-up. SnapStartexists for exactly this: it snapshots an initialised JVM after startup and restores from that image, which removes most of the penalty. Provisioned concurrency does the same job by keeping instances warm, and costs accordingly. Plain Lambda with a Spring Boot fat jar and neither of those is the configuration people benchmark and then write angry posts about.

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.

ConceptSQSPub/Sub
Time to processVisibility timeoutAck deadline
Give up after N triesRedrive policy → DLQDead-letter topic
OrderingFIFO queues onlyOrdering keys, per key
Fan-outSNS in front of queuesBuilt in — topic, many subscriptions
Two listeners, one handler
// 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.

Resist the wrapperThe instinct is to build a cloud-agnostic messaging layer so the service "doesn't care". It ends up exposing the union of both APIs, or the intersection — bloated or useless. Two thin adapters calling one handler is less code and easier to read than one abstraction pretending the clouds are the same.

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.

ConceptS3Cloud Storage
NamespaceBucket names global per partitionBucket names globally unique
Temporary accessPresigned URLSigned URL
Cool tiersStandard-IA, GlacierNearline, Coldline, Archive
Change eventsS3 notifications → SQS / SNS / LambdaNotifications → 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.

Time-limited download links
// 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());
Signed does not mean privateA signed URL is a bearer token in a query string. It lands in browser history, in referrer headers, and in any log that records full URLs. Keep the expiry short — minutes, not days — and never treat one as a permanent link you can email out.

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.

application-aws.yml
spring:
  config:
    import: aws-secretsmanager:/prod/order-service
  datasource:
    url: ${db-url}
    username: ${db-user}
    password: ${db-password}
application-gcp.yml
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.
A downloaded key is a liabilityA GCP service-account JSON or an AWS access key in an environment variable is a long-lived credential that never rotates, gets copied into local .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.

Cloud SQL — connector, not an IP
spring:
  datasource:
    url: jdbc:postgresql:///orders
    hikari:
      data-source-properties:
        socketFactory: com.google.cloud.sql.postgres.SocketFactory
        cloudSqlInstance: my-project:asia-south1:orders-db

One 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 itLeave it cloud-specific
The business handler both listeners callThe listener annotations
Property names the app readsHow those properties get populated
Health and readiness endpointsHow the platform probes them
The container imageThe 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.

The short versionPortability lives in the image and the handler, not in a wrapper around the cloud. Both queues redeliver, so be idempotent either way. Let the workload have an identity instead of a key. Watch CPU throttling on scale-to-zero, and size connection pools against how many instances you might end up with, not how many you have today.