Skip to main content

Choosing an S3 mock

Several projects answer the S3 API without AWS, and they are built for different jobs. This page says what LocalS3 is good at, and — just as usefully — when one of the others is the better choice, so that the decision doesn't need a weekend of trials.

The facts about the other projects are the stable ones: what the project is for, what it runs on, how it is licensed and how it is deployed. Feature-by-feature claims about someone else's software go stale, so check the current documentation of whichever you shortlist; the links are in the table. This page was last checked on 2026-09-19.

The short answer​

If you…Pick
test JVM code against S3, or embed an S3 server in a Java application or an IDELocalS3
test a lakehouse — Iceberg, Delta Lake, DuckLake, DuckDB — and don't want a catalog process beside the object storeLocalS3, which serves an Iceberg REST catalog on the same port
test code that uses the S3 Vectors APILocalS3, which implements it
need durable storage for something real: production, staging, a shared dev environmentMinIO, or Amazon S3 itself
need more of AWS than S3 — SQS, DynamoDB, Lambda, CloudFormationLocalStack
want to put an S3 API in front of a store you already have (a filesystem, Azure Blob, GCS)s3proxy
test from Python, Node or Go and only want a container to runany of them; LocalS3's Docker image works, but its integrations are JVM-side

What the projects are​

ProjectWhat it isRuntimeLicenseHow it runs
LocalS3An S3 mock for testing and local development, made to be embedded in a JVMJava 25, NettyApache 2.0Java API, JUnit 5, Spring Boot 3 and 4, Testcontainers, Docker (JVM or GraalVM native), executable jar
Adobe S3MockAn S3 mock for testingJava, Spring BootApache 2.0JUnit 4 and 5, Testcontainers, Docker
s3proxyAn S3 front end over other blob stores — filesystem, in-memory, Azure Blob, Google Cloud Storage and more — through Apache jcloudsJavaApache 2.0Executable jar, Docker, embeddable
MinIOA production object storage server that speaks S3GoAGPL-3.0Single binary, Docker, Kubernetes
LocalStackAn emulator for a large part of AWS, with S3 as one of its servicesPythonCommunity edition is free; see the project for the current termsDocker, Testcontainers

The licenses matter for more than compliance paperwork. LocalS3, S3Mock and s3proxy are Apache 2.0, so they can be a dependency of a closed-source product. MinIO is AGPL-3.0, which is fine for a container a test starts and stops, and a question worth asking a lawyer if it would ship inside something.

Where LocalS3 differs​

One process for a lakehouse test​

An Iceberg test normally needs two things running: an object store, and a catalog — Polaris, Lakekeeper, Nessie or a JDBC catalog on a database. LocalS3 serves an Iceberg REST catalog under /iceberg/v1 on the same port as the S3 API, off by default:

LocalS3 s3 = LocalS3.builder().port(29090).icebergCatalog(true).build();
s3.start();

RESTCatalog catalog = new RESTCatalog();
catalog.initialize("local", Map.of("uri", "http://localhost:29090/iceberg"));
catalog.createNamespace(Namespace.of("db"));

The tables live in LocalS3 itself, so an IN_MEMORY service holds them in memory and a PERSISTENCE service writes them to its data directory, and loadTable vends the endpoint, path-style setting and credentials of the service, so an engine configured with the catalog URI alone reaches the storage too. Namespaces, tables, views, commits and multi-table transactions are served, and a commit moves the table pointer with a compare-and-set, so a writer that loses a race gets 409 CommitFailedException and its client retries rather than losing rows.

Delta Lake rests on a different mechanism — If-None-Match: * creating _delta_log/<version>.json only when no other writer did — and that conditional write, including the 412 the losing writer gets, is covered end to end against delta-kernel-java.

S3 Vectors​

LocalS3 implements the Amazon S3 Vectors API: vector buckets, indexes, PutVectors / QueryVectors / GetVectors / ListVectors / DeleteVectors, vector bucket policies and tagging — 19 operations. Code that stores embeddings in S3 Vectors can be tested without an AWS account.

Persistence, initial data and seeders​

Three different things, and tests use all three:

  • IN_MEMORY, the default: nothing touches the disk. The content is bounded — a quarter of the max heap by default — so a test that writes large Parquet files gets 507 InsufficientStorage instead of taking the JVM that embeds the service down with an OutOfMemoryError.
  • PERSISTENCE: metadata in an H2 MVStore file, object content in a storage directory, surviving a restart. PersistencePolicy.FAST trades the changes of the last second for about half the write time and a hundredth of the writes on a bulk load, which is the trade a data directory built for a test can usually make.
  • A data path without PERSISTENCE is initial data: the service starts from what the directory holds and never writes back to it, so a fixture directory can be shared by many tests. localS3.reset() returns a service to it, far quicker than restarting. Directories of hundreds of thousands of objects open without loading all their metadata.

Where the fixtures are code rather than a directory, seeder(...) puts buckets and objects into the service before it accepts the first request, and again after every reset:

LocalS3.builder()
.seeder(fixtures -> {
fixtures.object("uploads", "hello.txt", "Hello!".getBytes(UTF_8));
fixtures.object("uploads", "images/logo.png", Path.of("src/test/resources/logo.png"));
})
.build();

Startup cost​

A mock that a test class starts is on the critical path of every build, so the numbers are worth measuring rather than asserting. On an Apple M5 Max with Temurin 25.0.2, with the standalone executable jar (10 MB) in IN_MEMORY mode:

WhatMeasured
java -jar local-s3-standalone.jar to "LocalS3 started", JVM boot included~275 ms
The first embedded service in a JVM that is already running (LocalS3.builder().port(0).build().start())~200 ms, mostly class loading
Every further service in that JVM1–2 ms

That last row is the one that decides a build's length: a test class per service, or a service per test method, costs milliseconds. localS3.reset() is cheaper still where one service is shared.

The reason is the dependency list rather than any trick — local-s3-rest pulls in three Netty modules, Jackson, netty-http-router, SLF4J and JSpecify, and no application framework:

+--- io.netty:netty-codec-http +--- tools.jackson.core:jackson-databind
+--- io.netty:netty-handler +--- io.github.robothy:netty-http-router
+--- io.netty:netty-transport +--- org.slf4j:slf4j-api
+--- tools.jackson.dataformat:jackson-dataformat-xml \--- org.jspecify:jspecify

A container-based mock pays Docker's startup instead, typically seconds for the first container in a build. LocalS3 has a Testcontainers module for when a container is what you want — a test of a non-JVM process, or isolation from the test JVM — but it is the option rather than the only way in.

Depth of the JVM integrations​

JUnit 5 — @LocalS3 on a test class starts one service shared by its methods and its @Nested classes; on a test method, one service of its own. It injects a configured S3Client, S3VectorsClient or LocalS3Endpoint into tests and lifecycle methods, and accessKey with secretKey turns on signature verification and signs the injected clients with the same pair. See JUnit 5.

Spring Boot — one artifact covers Spring Boot 3 and 4, and the starter defines rather more than a bean:

  • a LocalS3 bean configured by local-s3.* properties that map to the builder options, with LocalS3BuilderCustomizer beans for the rest;
  • S3Client, S3AsyncClient and S3Presigner beans pointed at the service, which back off if the application defines its own, and which start the service when created, so a @PostConstruct can use one even with a random port;
  • local-s3.seed.classpath and LocalS3Seeder beans for the initial data;
  • every committed S3Change published as an application event, on the thread that made the change, so a @TransactionalEventListener sees it when the transaction commits;
  • an Actuator health indicator, and Micrometer timers and gauges for requests, objects and bytes;
  • local-s3.enabled=false to leave the whole thing out, which is how the same application runs against Amazon S3 in production.

See the starter's README.

The endpoints around S3​

Clients rarely talk to S3 alone. LocalS3 answers, on the same port:

  • a stateless STS endpoint — AssumeRole, GetSessionToken, GetCallerIdentity — so Iceberg REST catalogs that vend temporary credentials, and the engines that use them, work;
  • a stateless KMS endpoint — GenerateDataKey, Encrypt, Decrypt, DescribeKey — so a client that wraps data keys with KMS, e.g. the Amazon S3 Encryption Client, runs through. Nothing is really encrypted;
  • static website hosting for unsigned requests, with index and error documents, directory redirects and routing rules, while an S3 client's signed requests keep their S3 semantics;
  • HTTPS with a certificate LocalS3 generates on startup, or one of mkcert, for clients that insist on TLS such as DuckDB — on the same port as plain HTTP, so both kinds of client share one endpoint;
  • a built-in console at /_admin/ui — one self-contained HTML page that lists the buckets and creates one, walks a bucket by its prefixes, previews or downloads an object, and uploads a file dropped on it or deletes one, so what is in the service can be looked at and changed rather than listed with aws s3 ls. It is far less than the MinIO console, and it is the thing one usually opens it for;
  • health check and admin endpoints for statistics, recent requests and resetting a service between tests.

Where LocalS3 is not the answer​

It is a mock, not storage. No erasure coding, no replication, no multi-node anything. A PERSISTENCE service survives a restart and is the right tool for a local development environment; it is not where production data belongs.

It is not all of AWS. If the test needs SQS beside S3, LocalStack emulates both and LocalS3 emulates one.

Some configuration is stored rather than acted on. Bucket lifecycle configurations are saved, and applied only on demand: a framework that configures one on startup works, nothing expires by itself, and a test applies them as of any date with POST /_admin/lifecycle or LocalS3#applyLifecycle. Server-side encryption headers and storage classes are accepted and recorded, and nothing is encrypted or tiered. Analytics, intelligent-tiering, inventory and metrics configurations are stored and read back, and no report or metric is ever produced and no object changes tier. RestoreObject completes at once. Bucket notification configurations are stored, and no notification is delivered — use change listeners instead, which are told of every committed change.

Some operations answer 501. GetObjectTorrent, SelectObjectContent, WriteGetObjectResponse, UpdateObjectEncryption, the object annotation operations (DeleteObjectAnnotation, GetObjectAnnotation, ListObjectAnnotations and PutObjectAnnotation) and the S3 Metadata operations (CreateBucketMetadataConfiguration, CreateBucketMetadataTableConfiguration, DeleteBucketMetadataConfiguration, DeleteBucketMetadataTableConfiguration, GetBucketMetadataConfiguration, GetBucketMetadataTableConfiguration, UpdateBucketMetadataAnnotationTableConfiguration, UpdateBucketMetadataInventoryTableConfiguration and UpdateBucketMetadataJournalTableConfiguration) are routed and answer 501 NotImplemented with an error naming the operation, so a client fails clearly instead of appearing to succeed. The full lists, checked against the router by a test so they can't drift, are in apis.md; a test also checks the operations named here.

Its integrations are JVM-side. The Docker image serves any client in any language, but the parts that make LocalS3 pleasant — @LocalS3, the Spring Boot starter, seeders, change listeners, getS3Manager() — are Java. A Python or Go project gets a container, which several other projects also provide.