Learn how to run DynamoDB locally using Docker, Testcontainers, Spring Boot and Quarkus for development and integration testing.

Reading time:

10–15 minutes
Simple Containerization of DynamoDB for Local Development

Image generated with AI

In this blog post, we revisit our previous embedded DynamoDB example and move it to a containerized setup based on Docker. The goal is to keep the business logic unchanged while moving DynamoDB Local out of the application itself and into a separate container.

In an earlier version of this setup, a broader AWS emulator such as LocalStack could be a convenient way to run DynamoDB locally. Starting March 23 2026, LocalStack distributes its Docker image as a unified, authenticated image that requires an auth token, including for CI usage, but older pinned images may become outdated over time and require more manual maintenance.

For this reason, this example uses the official Amazon DynamoDB Local Docker image instead. This keeps the setup focused, and avoids carrying an embedded dependency in the application just for local development.

Project Overview

We will reuse the same shopping cart example as before and focus only on the infrastructure changes needed to run DynamoDB locally in a containerized way. If you are already familiar with Amazon DynamoDB, feel free to skip ahead.

Source code for both Spring Boot and Quarkus is available on GitHub.

Architecture

The architecture of the project is slightly different compared to the embedded example, as in this case DynamoDB runs as a standalone container.

Data Model Design

PKTotal PriceNumber Of Items
110001
220003
375007

DynamoDB with Docker

Following tools and dependencies have been used to run the project:

  • JDK 25
  • Maven 3.9
  • IDE of your choice
  • Docker and Docker Compose

How to install Docker?

To run containers locally, I personally prefer Colima on macOS. It is a lightweight alternative to Docker Desktop and works well for day-to-day development. If you do not have a container runtime installed yet, you can start with Colima’s all-in-one installation guide. If you prefer Docker Desktop or use a different operating system, refer to the official Docker installation manuals.

Subscribe to my newsletter


First, let’s look at the necessary Maven dependencies. With this example we rely on Docker, so no embedded DynamoDB this time.

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>4.0.6</version>
		<relativePath/>
	</parent>

	<groupId>quickstarts</groupId>
	<artifactId>dynamodb-docker-spring</artifactId>
	<version>0.0.1-SNAPSHOT</version>

	<properties>
		<java.version>25</java.version>
	</properties>

	<dependencyManagement>
		<dependencies>
			<dependency>
				<groupId>io.awspring.cloud</groupId>
				<artifactId>spring-cloud-aws-dependencies</artifactId>
				<version>4.0.2</version>
				<type>pom</type>
				<scope>import</scope>
			</dependency>
		</dependencies>
	</dependencyManagement>

	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-webmvc</artifactId>
		</dependency>

		<dependency>
			<groupId>io.awspring.cloud</groupId>
			<artifactId>spring-cloud-aws-docker-compose</artifactId>
		</dependency>

		<dependency>
			<groupId>io.awspring.cloud</groupId>
			<artifactId>spring-cloud-aws-starter-dynamodb</artifactId>
		</dependency>

		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<optional>true</optional>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-webmvc-test</artifactId>
			<scope>test</scope>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-testcontainers</artifactId>
			<scope>test</scope>
		</dependency>

		<dependency>
			<groupId>org.testcontainers</groupId>
			<artifactId>testcontainers-junit-jupiter</artifactId>
			<scope>test</scope>
		</dependency>
	</dependencies>

  <!-- plugins -->

</project>
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>quickstarts</groupId>
    <artifactId>dynamodb-docker-quarkus</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <packaging>quarkus</packaging>

    <properties>
        <compiler-plugin.version>3.15.0</compiler-plugin.version>
        <maven.compiler.release>25</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
        <quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
        <quarkus.platform.version>3.34.5</quarkus.platform.version>
        <skipITs>true</skipITs>
        <surefire-plugin.version>3.5.4</surefire-plugin.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>${quarkus.platform.group-id}</groupId>
                <artifactId>${quarkus.platform.artifact-id}</artifactId>
                <version>${quarkus.platform.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>${quarkus.platform.group-id}</groupId>
                <artifactId>quarkus-amazon-services-bom</artifactId>
                <version>${quarkus.platform.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-rest-jackson</artifactId>
        </dependency>

        <dependency>
            <groupId>io.quarkiverse.amazonservices</groupId>
            <artifactId>quarkus-amazon-dynamodb-enhanced</artifactId>
        </dependency>

        <dependency>
            <groupId>software.amazon.awssdk</groupId>
            <artifactId>url-connection-client</artifactId>
        </dependency>

        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-arc</artifactId>
        </dependency>

        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-junit</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>io.rest-assured</groupId>
            <artifactId>rest-assured</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers</artifactId>
            <version>2.0.5</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <!-- plugins -->
    
</project>

You will need to set basic configuration, so that you can run the example successfully.

application-dev.yaml
spring:
  docker:
    compose:
      enabled: true
  cloud:
    aws:
      credentials:
        access-key: testKey
        secret-key: testSecret
      dynamodb:
        region: eu-central-1
        endpoint: "http://localhost:8000"
application.properties
%dev.quarkus.dynamodb.endpoint-override=http://localhost:8000
%dev.quarkus.dynamodb.aws.region=eu-central-1
%dev.quarkus.dynamodb.aws.credentials.type=static
%dev.quarkus.dynamodb.aws.credentials.static-provider.access-key-id=test
%dev.quarkus.dynamodb.aws.credentials.static-provider.secret-access-key=test

%prod.quarkus.devservices.enabled=false

Our Docker Compose snippet defines a dynamodb-local service, which runs the official amazon/dynamodb-local image and exposes the DynamoDB service. Both framewords recognize the Docker Compose file and start the container at startup. Quarkus Compose Dev Services will automatically recognize the compose file based on the filename compose-devservices.yml and start the container. Spring Boot Dev Services will search for compose.yml by default.

YAML
services:
  dynamodb-local:
    image: "amazon/dynamodb-local:latest"
    container_name: dynamodb-local
    ports:
      - "8000:8000"

Following class handles one small but useful job: it prepares the DynamoDB table automatically when the application runs in development mode. It is annotated so that this startup logic is active only in the local development profile and does not affect production builds.

Application.java
@Slf4j
@SpringBootApplication
public class Application {

	static void main(String[] args) {
		SpringApplication.run(Application.class, args);
	}

	@Bean
	@Profile({ "dev", "test" })
	CommandLineRunner commandLineRunner(DynamoDbEnhancedClient client, DynamoDbTableNameResolver tableNameResolver) {
		var tableName = tableNameResolver.resolve(ShoppingCart.class);
		return _ -> {
			try {
				client.table(tableName, TableSchema.fromBean(ShoppingCart.class)).createTable();
			} catch (ResourceInUseException e) {
				log.info("Table already created");
			}
		};
	}
}

We create our table in CommandLineRunner. The runner method resolve the table name with DynamoDb integration and using the DynamoDbEnhancedClient we create out table.

Application.java
@QuarkusMain
@IfBuildProfile("dev")
public class Application implements QuarkusApplication {

    @Inject
    @NamedDynamoDbTable(ShoppingCart.TABLE_NAME)
    private DynamoDbTable<ShoppingCart> dynamoDbTable;

    @Override
    public int run(String... args) {
        try {
            dynamoDbTable.createTable();
        } catch (ResourceInUseException exception) {
            Log.info("Table already created");
        }

        Quarkus.waitForExit();
        return 0;
    }
}

The main method starts a custom QuarkusApplication. Inside that class we use, @NamedDynamoDbTable annotation to inject an instance of DynamoDbTable for ShoppingCard. On startup, the run() method creates the table.

The controller, service, and repository layers are unchanged from the embedded DynamoDB version. This is intentional as the goal of this example is to replace the local DynamoDB runtime, not the application’s business logic. If you want a detailed walkthrough of the API and persistence flow, see the previous post.

Testing

For testing, I do not start DynamoDB manually with Docker or Docker Compose. Instead, the test suite uses Testcontainers to provision DynamoDB Local on demand. This keeps the test setup self-contained: each test run gets a fresh container, the host port is assigned dynamically, and the container is cleaned up automatically afterward. As a result, the tests do not depend on a pre-provisioned local DynamoDB instance and are less likely to interfere with local development data or ports.

In Spring Boot, we leave the lifecycle management of DynamoDB container to Testcontainers via JUnit extensions @Container annotation with a combination of @DynamicPropertySource which allows adding dynamic property values after the container has been started.

In practice, this means the test infrastructure starts the official amazon/dynamodb-local image, reads the mapped host port, and injects the resulting endpoint into the
spring.cloud.aws.dynamodb.endpoint property so the application connects to the test container instead of AWS. Compared to the Quarkus example, the responsibility for schema initialization is separated differently. In Quarkus, the test lifecycle manager both starts the container and creates the table. In Spring Boot, the table creation is handled by the application itself through a CommandLineRunner in Application class. The following interface wires DynamoDB Local into the Spring Boot test:

DynamoDbContainer.java
public interface DynamoDbContainer {

    int DEFAULT_PORT = 8000;

    DockerImageName DOCKER_IMAGE = DockerImageName.parse("amazon/dynamodb-local:latest");

    @Container
    GenericContainer CONTAINER = new GenericContainer(DOCKER_IMAGE).withExposedPorts(DEFAULT_PORT);

    @DynamicPropertySource
    static void overrideProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.cloud.aws.dynamodb.endpoint", () -> "http://localhost:" + CONTAINER.getMappedPort(DEFAULT_PORT));
    }
}

In Quarkus, the DynamoDB Local bootstrap logic lives in DynamoDbContainerLifecycleManager, which integrates with the test lifecycle through QuarkusTestResourceLifecycleManager. When the test suite starts, DynamoDbContainerLifecycleManager launches the official amazon/dynamodb-local image with Testcontainers, reads the mapped host port, and returns the quarkus.dynamodb.endpoint-override property so Quarkus points the DynamoDB client to that container instead of AWS. After the container is up, the same class prepares the required schema by creating a DynamoDbClient with static test credentials, building a DynamoDbEnhancedClient, and creating the shopping_cart table from the ShoppingCart bean definition.

This custom Testcontainers setup is intentional. Quarkus does provide DynamoDB Dev Services, which supports stack providers like LocalStack, MiniStack, Moto, Floci and possibly others in the future. In this example, I want tests to run directly against the official DynamoDB Local image, so a custom GenericContainer is a better fit. The following class wires DynamoDB Local into the Quarkus test lifecycle:

DynamoDbContainerLifecycleManager.java
public class DynamoDbContainerLifecycleManager implements QuarkusTestResourceLifecycleManager {

    private static final int DEFAULT_PORT = 8000;

    private GenericContainer container;

    @Override
    public Map<String, String> start() {
        var dockerImage = DockerImageName.parse("amazon/dynamodb-local:latest");
        container = new GenericContainer(dockerImage).withExposedPorts(DEFAULT_PORT);
        container.start();

        var endpoint = "http://localhost:" + container.getMappedPort(DEFAULT_PORT);
        createShoppingCartTable(endpoint);
        return Collections.singletonMap("quarkus.dynamodb.endpoint-override", endpoint);
    }

    @Override
    public void stop() {
        container.stop();
    }

    private void createShoppingCartTable(String endpoint) {
        var provider = StaticCredentialsProvider.create(AwsBasicCredentials.create("test", "test"));
        try (var dynamoDbClient = DynamoDbClient.builder()
                .endpointOverride(URI.create(endpoint))
                .region(Region.EU_CENTRAL_1)
                .credentialsProvider(provider)
                .build()) {

            var enhancedClient = DynamoDbEnhancedClient.builder()
                    .dynamoDbClient(dynamoDbClient)
                    .build();

            try {
                enhancedClient.table(ShoppingCart.TABLE_NAME, TableSchema.fromBean(ShoppingCart.class)).createTable();
            } catch (ResourceInUseException ignored) {
                Log.info("Table already created");
            }
        }
    }
}

And this is how our test class looks after adding the annotations that let Spring Boot and Quarkus handle the setup for us.

Java
@Testcontainers
@ActiveProfiles("test")
@AutoConfigureRestTestClient
@ImportTestcontainers(DynamoDbContainer.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ShoppingCartControllerTest {

    @Autowired
    RestTestClient restTestClient;

    @Test
    void testCrudShoppingCart() {
        var expectedTotalPrice = new BigDecimal("5000");
        var expectedNumberOfItems = 7;

        // create a new shopping cart for current user
        var uri = restTestClient.post()
                .uri("/v1/shopping-carts")
                .body(new ShoppingCartRequest(expectedNumberOfItems, expectedTotalPrice))
                .exchange()
                .expectStatus()
                .is2xxSuccessful()
                .returnResult()
                .getResponseHeaders()
                .getLocation();

        assertNotNull(uri);

        // get the shopping cart for current user
        var response = restTestClient.get()
                .uri(uri)
                .exchange()
                .expectStatus()
                .is2xxSuccessful()
                .expectBody(ShoppingCart.class)
                .returnResult()
                .getResponseBody();

        assertNotNull(response);
        assertEquals(expectedTotalPrice, response.getTotalPrice());
        assertEquals(expectedNumberOfItems, response.getNumberOfItems());

        // delete the shopping cart for current user
        restTestClient.delete()
                .uri(uri)
                .exchange()
                .expectStatus()
                .is2xxSuccessful();

        // try to get the shopping cart for current user again
        restTestClient.get()
                .uri(uri)
                .exchange()
                .expectStatus()
                .isNotFound();
    }
}

@Testcontainers enables Testcontainers support for the JUnit 5 test, while @ImportTestcontainers imports the shared container definition together with its dynamic property registration.

Java
@QuarkusTest
@QuarkusTestResource(DynamoDbContainerLifecycleManager.class)
class ShoppingCartResourceTest {

    @Test
    void testCrudOnShoppingCart() {
        var expectedPrice = "3000";
        var expectedNumberOfItems = 50;

        // create a new shopping cart for current user
        var shoppingCartPrice = new ShoppingCartRequest(expectedNumberOfItems, new BigDecimal(expectedPrice));
        var location = given()
                .body(shoppingCartPrice)
                .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
                .when()
                .post("/v1/shopping-carts")
                .then()
                .statusCode(201)
                .extract()
                .header(HttpHeaders.LOCATION);

        assertNotNull(location);

        // get the shopping cart for current user
        given().baseUri(location)
                .when()
                .get()
                .then()
                .statusCode(200)
                .body("totalPrice", equalTo(Integer.valueOf(expectedPrice)))
                .body("numberOfItems", equalTo(expectedNumberOfItems));

        // delete the shopping cart for current user
        given().baseUri(location)
                .when()
                .delete()
                .then()
                .statusCode(204);

        // try to get the shopping cart for current user again
        given().baseUri(location)
                .when()
                .get()
                .then()
                .statusCode(404);
    }
}

@QuarkusTestResource registers a custom test resource that starts and stops a Testcontainers-based DynamoDB instance and injects its runtime configuration into the test. Together, these annotations let the test run against a temporary DynamoDB container with the required setup handled automatically.

To run the service locally, execute the following command or run with your preferred IDE.

Bash
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
Bash
./mvnw quarkus:dev

Conclusion

In this post, we moved our local DynamoDB setup from an embedded dependency to a containerized approach based on the official DynamoDB Local image. This keeps the application code unchanged while cleanly separating the local database into an external service managed by Docker in development and Testcontainers in tests.

By reusing the same business logic and only changing the surrounding infrastructure, we get a setup that is easier to reason about, closer to a real service boundary, and better isolated during integration testing. The complete source code for both the Spring Boot and Quarkus examples is available on GitHub.

Subscribe to my newsletter




Latest Posts


Leave a Reply

Your email address will not be published. Required fields are marked *