Say your Spring Boot application needs to upload a file to cloud storage, read a message from a queue, or fetch a password from the AWS secrets store. How do you do that from code? Good news: the exact same code will work on your laptop and on a server in the cloud — without a single change. To make that happen, you need to understand three things: the SDK (the library for talking to AWS), the credentials chain (how the code learns whose permissions it is running under), and LocalStack (how to test all of this locally without touching the real cloud).

This article is about integration specifically from Spring Boot, so the examples are in Java. But the ideas themselves (an SDK, a chain for obtaining permissions, a local imitation) are the same for any language — AWS has SDKs for Python, Node.js, Go and others.

SDK v2 and clients as beans

An SDK (Software Development Kit) is a library from AWS that your code uses to talk to the cloud. Instead of hand-assembling HTTP requests to the API, you call methods like s3Client.putObject(...), and the SDK builds the request, signs it and sends it for you.

An important detail: the AWS SDK for Java has two generations. The old one (v1, packages start with com.amazonaws) is considered deprecated — new projects are not written on it. The current one is v2, its packages start with software.amazon.awssdk. From here on we only talk about v2.

Each AWS service has its own client: S3Client for storage, SqsClient for queues, and so on. These clients are thread-safe (they can safely be used from several threads at once) and heavyweight to create, so you should create them once and reuse them. In Spring that means declaring the client as a bean.

@Configuration
public class AwsConfig {

    @Bean
    SqsClient sqsClient() {
        return SqsClient.builder()
            .region(Region.EU_CENTRAL_1)
            .build();
    }

    @Bean
    S3Client s3Client() {
        return S3Client.builder()
            .region(Region.EU_CENTRAL_1)
            .build();
    }
}

Here region is the AWS geographic region (for example, eu-central-1 — Frankfurt) where your data lives. Timeouts and built-in request retries are configured through overrideConfiguration. By default the SDK retries failed requests on its own, with a growing pause between attempts. If you add your own retry mechanism on top of that, you get "a retry on top of a retry" — and when a service fails, that turns into an avalanche of requests. So retries are configured either in the SDK or your own — but not both at once.

The credentials chain: where the access permissions come from

Look at the code above again. Where is the login and password for AWS? There is none — and that is not an oversight, it is the whole idea. The credentials chain is the mechanism by which the SDK finds on its own whose permissions to run under, checking sources in order until it finds the first one that works.

An analogy: you arrive at the office and tap your badge on the turnstile. You are not carrying a safe full of permissions with you — the system already knows what you are allowed to do. The credentials chain works similarly: the code does not store keys, it just "taps the badge" that the environment hands it.

The order in which SDK v2 looks for permissions (simplified):

  1. Java System Propertiesaws.accessKeyId and aws.secretAccessKey.
  2. Environment variablesAWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
  3. Web identity token — this is how a role is granted to an application in Kubernetes/EKS (it is called IRSA — a role bound to a ServiceAccount).
  4. A profile from the ~/.aws/credentials file — what you configure on your laptop with aws configure or SSO.
  5. A container role (ECS task role) — if the AWS_CONTAINER_CREDENTIALS_RELATIVE_URI variable is set.
  6. An instance role (EC2) — permissions are requested from the machine's metadata service.

The effect is powerful: the same code on a laptop runs under your personal profile, in Kubernetes — under the pod's role, on EC2 — under the machine's role. It is not the code that changes, it is the environment.

The most common mistake is writing the keys straight into application.yml:

aws:
  accessKeyId: AKIA...
  secretKey: wJalr...

You must not do this. The keys will end up in git, in logs, in copies of the config; nobody rotates them (changes them periodically), and moving the service to another environment becomes agony. If a line like aws.secret shows up in the repository — consider that a leak has already happened. Hand permissions off to the credentials chain, and never keep keys in the code.

Spring Cloud AWS: less boilerplate code

On top of the SDK there is Spring Cloud AWS (the awspring project) — a layer that takes over the routine: it creates clients from settings in application.yml and gives you convenient high-level tools. The most useful one is an SQS queue listener in the familiar Spring style, like a message handler:

@Component
public class OrderEventsSqsConsumer {

    @SqsListener("order-events")
    public void handle(OrderEventPayload payload) {
        orderService.register(payload);
    }
}

The @SqsListener annotation polls the queue on its own, turns the JSON message into an object, and deletes the message after it is processed successfully. Only one thing is required of you — make the handler idempotent, that is, safe against processing the same message twice. This matters because SQS guarantees at-least-once delivery: a single message may arrive twice, and your code must not break because of it (for example, by creating a duplicate order).

The second useful thing is loading secrets and settings from AWS as plain Spring properties. One line in the config is enough:

spring:
  config:
    import: aws-secretsmanager:/prod/order-service/

After that, database passwords and other secrets live only in AWS Secrets Manager, and the application receives them at startup. In code you access them like ordinary @Value properties — and you store none of the values anywhere. More on secrets and metrics is in the article on security and observability.

Testing with LocalStack

How do you test code that talks to AWS without paying for the real cloud and without depending on the internet? The answer is LocalStack: a program that runs locally (in Docker) and pretends to be a set of AWS services. Your code thinks it is talking to a real S3 or SQS, when in fact it is talking to a local imitation.

The combination of LocalStack + Testcontainers (a library that spins up Docker containers straight from a test and tears them down afterward) makes an integration test against "AWS" as simple as a test against a local database:

@Testcontainers
class OrderEventsSqsConsumerTest {

    @Container
    static LocalStackContainer localstack =
        new LocalStackContainer(DockerImageName.parse("localstack/localstack:3"))
            .withServices(SQS);

    @DynamicPropertySource
    static void aws(DynamicPropertyRegistry registry) {
        registry.add("spring.cloud.aws.endpoint", () -> localstack.getEndpoint().toString());
        registry.add("spring.cloud.aws.region.static", localstack::getRegion);
    }

    @Test
    void processesOrderEvent() {
        sendToQueue("order-events", paidOrderPayload());
        await().untilAsserted(() -> assertThat(repository.findEvents()).hasSize(1));
    }
}

The key line is spring.cloud.aws.endpoint: it tells Spring Cloud AWS to talk not to the real cloud but to the LocalStack address. The container picks a free port itself, so we take the address from localstack.getEndpoint().

It is important to understand the limits: LocalStack imitates the behavior of services well (queues accept and hand back messages, buckets store files), but it does not check access permissions (IAM policies) and does not reproduce the limits and pricing of real AWS. So the right split of checks is this: pure business logic is tested without AWS at all (by replacing the dependency with a stub), integration with cloud services — on LocalStack, and real permissions and networking — on a test environment in the real cloud.

The cost of calls

In the cloud, habits that are harmless on your own server cost money: every request to AWS is a line on the bill. Two places where this is most noticeable:

  • Requests to S3 in a loop, one object at a time — better to do it in batches or by listing with a prefix, otherwise each object becomes a separate paid request.
  • Traffic through NAT — outbound calls to AWS services are billed; to get rid of them, there are VPC endpoints (see networking).

This is not premature optimization, it is hygiene: the price per request is visible in the price list up front.

Common mistakes

MistakeHow it endsWhat to do
Keys in application.yml or gitLeak, manual rotation, incidentRely on the credentials chain: IRSA, task role, SSO
SDK v1 in new codeTwo libraries in one project, old bugsOnly software.amazon.awssdk (v2)
Your own retry on top of the SDK's retryAn avalanche of retries on failureConfigure retries in the SDK, turn your own off
Tests against real AWSSlow, expensive, flaky, needs internetStub for logic, LocalStack for integration
Short SQS pollingPaying for empty responses around the clockLong polling, waitTimeSeconds: 20
A new client on every callHundreds of TLS handshakes, resource leakOne client bean per service

Where this is used

The combination of "SDK + credentials chain + LocalStack" is the basic skeleton of any application that lives in AWS: a microservice reading orders from a queue; a backend putting user-uploaded files into storage; a service that fetches passwords from Secrets Manager at startup. Everywhere the same technique: the code knows nothing about keys, permissions come from the environment, and local tests run through an imitation.

The most common pitfalls: hardcoded access keys (the number-one cause of leaks), creating a new client on every request (running out of resources under load), a forgotten idempotency of the queue handler (duplicate orders and payments on redelivery), and trying to test straight against real AWS (flaky tests and an unexpected bill).

What to study next:

  • Fundamentals — how accounts, regions and the basic services are arranged.
  • IAM — roles, policies and where the credentials chain gets permissions from.
  • Security and observability — Secrets Manager and CloudWatch metrics from code.
  • Working with object storage — uploading files, temporary links, multipart.
  • DynamoDB — if the application needs a managed NoSQL database.