Building Cloud-Ready Apps Locally: Spring Boot, AWS, and LocalStack in Action

Developing an application with AWS services can introduce significant local‑development hurdles. Often, developers don’t receive timely AWS access, or a sysadmin inadvertently grants credentials for the wrong account – only to fix the error a week later. Then, when engineers discover they still lack permissions for certain resources, they must endure yet another week-long wait […]

May 13, 2025 - 17:32
 0
Building Cloud-Ready Apps Locally: Spring Boot, AWS, and LocalStack in Action

Developing an application with AWS services can introduce significant local‑development hurdles. Often, developers don’t receive timely AWS access, or a sysadmin inadvertently grants credentials for the wrong account – only to fix the error a week later. Then, when engineers discover they still lack permissions for certain resources, they must endure yet another week-long wait to secure the proper rights. All the while, the project deadline remains unchanged.

AWS services can be expensive during development and testing. Every resource – EC2 instances, databases, API calls, and storage – incurs per-use charges. Running multiple test environments or leaving resources idle quickly raises costs. Data transfer fees and premium services add even more charges.

LocalStack to the rescue!

Introducing LocalStack

LocalStack emulates AWS services on your local machine, allowing you to develop and test applications without provisioning real cloud resources. It provides realistic implementations of services like S3, Lambda, DynamoDB, and API Gateway. So your code can interact with LocalStack just like it would interact with real AWS services. By running LocalStack entirely locally, you can iterate faster, avoid unexpected cloud charges, and uncover integration issues early.

LocalStack also integrates smoothly with CI/CD pipelines and infrastructure-as-code tools like Terraform or the Serverless Framework, ensuring consistent environments from development through to production.

You can install LocalStack using an OS-specific installer, LocalStack Desktop application, or Docker by following the installation instructions. However, Testcontainers provides a LocalStack module, which makes it easy to use for local development and testing.

Let’s see how we can build a Spring Boot application using AWS S3 and SQS services with LocalStack by implementing a hypothetical scenario:

A message with an ID and content properties will be published to an SQS queue, and the listener will consume the message and store the message content in an S3 bucket with the message ID as the key. We should be able to retrieve the message content from the S3 bucket by key ID.

You can find the sample application code here.

Create a Spring Boot application with Spring Cloud AWS dependencies

Spring Cloud AWS is a community-driven project that simplifies using AWS services in Spring-powered applications. Spring Cloud AWS provides higher-level abstractions to interact with AWS services such as S3, SQS, SNS, and others, using the Spring programming model.

Prerequisites

To follow along with this tutorial, you need to have the following installed:

  • JDK 17 or later
  • Docker

As of now, Spring Initializr doesn’t provide support for Spring Cloud AWS dependencies. So, let’s create a Spring Boot application by selecting the Testcontainers dependency. You can create a Spring Boot project directly from IntelliJ IDEA Ultimate.

Once the project is created, let’s add the Spring Cloud AWS dependencies manually.

Add the following dependencies to the pom.xml:


   ...
   3.3.0



   
     
         io.awspring.cloud
         spring-cloud-aws-dependencies
         ${awspring.version}
         pom
         import
     
   



   
       io.awspring.cloud
       spring-cloud-aws-starter-s3
   
   
       io.awspring.cloud
       spring-cloud-aws-starter-sqs
   

   
       org.springframework.boot
       spring-boot-starter-test
       test
   
   
       org.springframework.boot
       spring-boot-testcontainers
       test
   
   
       org.testcontainers
       junit-jupiter
       test
   
   
       io.awspring.cloud
       spring-cloud-aws-testcontainers
       test
   
   
       org.testcontainers
       localstack
       test
   
   
       org.awaitility
       awaitility
       test
   

In the pom.xml, we have:

  • Configured the spring-cloud-aws-dependencies BOM (bill of materials) with version 3.3.0.
  • Added the spring-cloud-aws-starter-s3 and spring-cloud-aws-starter-sqs dependencies to enable us to work with S3 and SQS.
  • Added the Testcontainers LocalStack dependency so that we can create a LocalStack instance as a Docker container.
  • Added the spring-cloud-aws-testcontainers dependency so that we can use Spring Boot’s ServiceConnection support for LocalStack.
  • Added the Awaitility dependency to test asynchronous workflows such as publishing a message to an SQS queue and verifying the result.

Configure application properties

Instead of hard-coding the S3 bucket name and SQS queue name, let’s make them configurable properties.

In the src/main/resources/application.properties file, configure the following properties:

app.bucket-name=testbucket
app.queue-name=testqueue

Create a Java record to store these properties and access them in a type-safe manner using @ConfigurationProperties as follows:

package com.jetbrains.demo;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;

@ConfigurationProperties(prefix = "app")
public record ApplicationProperties(
    @DefaultValue("testqueue") String queueName,
    @DefaultValue("testbucket") String bucketName) {}

Save and retrieve data from an S3 bucket

Spring Cloud AWS offers higher-level abstractions, such as S3Client and S3Template, providing methods to enable the most common use cases to work with AWS S3.

Let’s create a class called StorageService and implement methods to upload and download data from our S3 bucket.

package com.jetbrains.demo;

import io.awspring.cloud.s3.S3Template;
import java.io.IOException;
import java.io.InputStream;
import org.springframework.stereotype.Service;

@Service
public class StorageService {
   private final S3Template s3Template;

   public StorageService(S3Template s3Template) {
       this.s3Template = s3Template;
   }

   public void upload(String bucketName, String key, InputStream stream) {
       this.s3Template.upload(bucketName, key, stream);
   }

   public InputStream download(String bucketName, String key) throws IOException {
       return this.s3Template.download(bucketName, key).getInputStream();
   }

   public String downloadAsString(String bucketName, String key) throws IOException {
       try (InputStream is = this.download(bucketName, key)) {
           return new String(is.readAllBytes());
       }
   }
}

Publish and consume messages from an SQS queue

Create a Java record called Message:

package com.jetbrains.demo;

import java.util.UUID;

public record Message(UUID id, String content) {}

Create a class with the name MessagePublisher to publish a message to the SQS queue.

package com.jetbrains.demo;

import io.awspring.cloud.sqs.operations.SqsTemplate;
import org.springframework.stereotype.Service;

@Service
public class MessagePublisher {
   private final SqsTemplate sqsTemplate;

   public MessagePublisher(SqsTemplate sqsTemplate) {
       this.sqsTemplate = sqsTemplate;
   }

   public void publish(String queueName, Message message) {
       sqsTemplate.send(to -> to.queue(queueName).payload(message));
   }
}

Create a class with the name MessageListener to consume the message from the SQS queue and store the message content in the S3 bucket.

package com.jetbrains.demo;

import io.awspring.cloud.sqs.annotation.SqsListener;
import org.springframework.stereotype.Service;

import java.io.ByteArrayInputStream;
import static java.nio.charset.StandardCharsets.UTF_8;

@Service
public class MessageListener {
   private final StorageService storageService;
   private final ApplicationProperties properties;

   public MessageListener(StorageService storageService, ApplicationProperties properties) {
       this.storageService = storageService;
       this.properties = properties;
   }

   @SqsListener(queueNames = {"${app.queue-name}"})
   public void handle(Message message) {
       var bucketName = this.properties.bucketName();
       var key = message.id().toString();
       var inputStream = new ByteArrayInputStream(message.content().getBytes(UTF_8));
       this.storageService.upload(bucketName, key, inputStream);
   }
}

We have implemented our use case using AWS S3 and SQS services. Note that there is nothing specific to LocalStack in our implementation.

Now that we have all the pieces together, let’s see how we can test the end-to-end flow using LocalStack without having to talk to real AWS services.

Testing AWS Service integrations with LocalStack

We can spin up an instance of LocalStack as a Docker container using the Testcontainers localstack module. Then, by registering a bean of LocalStackContainer with @ServiceConnection annotation, the AWS connection properties such as AWS endpoint, region, and others will be auto-configured.

LocalStack comes with a tool called awslocal, similar to AWS CLI, that can be used to create AWS resources such as S3 buckets, SQS queues, and more. So, once the LocalStack container is started, we can create S3 buckets and SQS queues using Testcontainers’ container.execInContainer() method.

package com.jetbrains.demo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.localstack.LocalStackContainer;
import org.testcontainers.utility.DockerImageName;

import java.io.IOException;

@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {
   @Autowired
   ApplicationProperties properties;

   @Bean
   @ServiceConnection
   LocalStackContainer localstackContainer() throws IOException, InterruptedException {
       LocalStackContainer localStack = new LocalStackContainer(DockerImageName.parse("localstack/localstack:4.3.0"));
       localStack.start();
       localStack.execInContainer("awslocal", "s3", "mb", "s3://" + properties.bucketName());
       localStack.execInContainer("awslocal", "sqs", "create-queue", "--queue-name", properties.queueName());
       return localStack;
   }
}

We have created an S3 bucket and SQS queue with the names configured in the application.properties file.

Now let’s write a test to publish a message to the SQS queue and verify that the message exists in the S3 bucket.

package com.jetbrains.demo;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;

import java.time.Duration;
import java.util.UUID;

import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;

@SpringBootTest
@Import(TestcontainersConfig.class)
class MessageProcessingTest {

   @Autowired
   StorageService storageService;

   @Autowired
   MessagePublisher publisher;

   @Autowired
   ApplicationProperties properties;

   @Test
   void shouldHandleMessageSuccessfully() {
       var message = new Message(UUID.randomUUID(), "Hello World");
       publisher.publish(properties.queueName(), message);

       await().pollInterval(Duration.ofSeconds(2))
               .atMost(Duration.ofSeconds(10))
               .ignoreExceptions()
               .untilAsserted(() -> {
                   String msg = storageService.downloadAsString(
                           properties.bucketName(), message.id().toString());
                   assertThat(msg).isEqualTo("Hello World");
               });
   }
}

Let’s understand what is happening in the above test:

  • A Message instance is published using the MessagePublisher.publish() method
  • Using Awaitility, we keep looking for an object in the S3 bucket with the message ID as the key, waiting at most 10 seconds with a polling interval of two seconds.
  • If the message is found, then we are verifying the expected content.
  • If no object is found in S3 with the given key within 10 seconds, then the test will fail.

We’ve seen how we can use LocalStack for testing the logic of AWS service integrations. Now, let’s see how we can use the same configuration for local development as well.

Local development with LocalStack

During local development, we may also want to start the application and interact with it by accessing the UI or invoking API endpoints, for instance.

We can therefore create a TestApplication class under src/test/java that will load the bean configurations from the TestcontainersConfiguration to start the application.

package com.jetbrains.demo;

import org.springframework.boot.SpringApplication;

public class TestApplication {
   public static void main(String[] args) {
       SpringApplication.from(Application::main)
               .with(TestcontainersConfig.class)
               .run(args);
   }
}

Now you can run this class to start our Spring Boot application by automatically starting the application dependencies (LocalStack in this case) with the AWS connection parameters autoconfigured.

This greatly simplifies the local development and testing of the application using AWS services.

Summary

LocalStack dramatically improves the developer experience by running a fully functional, local AWS cloud sandbox on your machine. Instead of waiting for IAM tickets, wrestling with slow deployments, or worrying about live resources and associated costs, you can spin up S3 buckets, Lambda functions, DynamoDB tables, and more in seconds – and iterate against them just like the real AWS APIs. This instant feedback loop helps you catch integration issues early, simplifies debugging, and lets your CI pipelines run reliably without external dependencies.

Best of all, because everything runs locally, you avoid paying for development and test infrastructure. This means there are no more surprise cloud bills for idle EC2 instances or test databases, and it allows you to focus on writing great code rather than tracking down charges.

You can find the sample application code here.

To explore more about building applications using Spring Cloud AWS and LocalStack, check out the documentation at Spring Cloud AWS and LocalStack.