Embedding LocalS3
LocalS3 runs inside a JVM as well as on its own: started from Java code, by an IDE plugin for as long as the IDE runs,
as a bean of a Spring Boot application, per test with a JUnit 5 annotation, or in a container managed by
Testcontainers. All artifacts are published to Maven Central under the group io.github.robothy, and require Java 25.
Java API
<dependency>
<groupId>org.mvnsearch</groupId>
<artifactId>local-s3-rest</artifactId>
<version>last_version</version>
</dependency>
Try it without a project
The executable jar carries the Java API and everything it needs, so a single source
file run by java tries the embedded service without a build. A compact source file, Java 25, needs no class
declaration and imports java.base:
// Demo.java
import com.robothy.s3.rest.LocalS3;
void main() {
var s3 = LocalS3.builder().port(19090).build();
s3.start();
IO.println("LocalS3 started at http://localhost:19090, press Enter to stop");
IO.readln();
s3.shutdown();
}
java -cp local-s3-standalone-2.5.0.jar Demo.java
In-memory mode
By default, LocalS3 runs in IN_MEMORY mode and listens on 127.0.0.1:29090; all data and metadata are kept in memory.
The objects of an IN_MEMORY service take at most a quarter of the max heap by default, so that tests which write
large files, e.g. Parquet files of DuckDB or Iceberg, don't run the application or IDE that embeds LocalS3 out of
heap: an upload beyond the limit is answered with 507 InsufficientStorage. Set the limit with
storage(storage -> storage.maxInMemoryBytes(bytes)), LOCAL_S3_IN_MEMORY_MAX_BYTES or
local-s3.in-memory.max-size, or use PERSISTENCE mode for data that large.
LocalS3 localS3 = LocalS3.builder().build();
localS3.start();
Call shutdown(), or close(), since LocalS3 is AutoCloseable, to stop the service gracefully.
localS3.shutdown();
port(0) binds a random free port, which getPort() returns once the service is started. acceptFromAnyHost() binds
every interface instead of 127.0.0.1.
endpoint() is the URL that clients reach the running service at, so nothing has to assemble it from the scheme, the
bind host and the port:
S3Client s3 = S3Client.builder()
.endpointOverride(URI.create(localS3.endpoint())) // e.g. http://127.0.0.1:29090
.build();
A service bound to every interface is reached at the loopback address, 127.0.0.1, an IPv6 bind host is bracketed, and
a service that serves HTTPS has an https endpoint. A service that was given a random port has none
until it is started, so endpoint() throws an IllegalStateException before that.
Persistence mode
When LocalS3 runs in persistence mode, a data path is required. LocalS3 loads data from and stores all data into the specified path.
LocalS3 localS3 = LocalS3.builder()
.mode(LocalS3Mode.PERSISTENCE)
.dataPath("/var/lib/local-s3")
.build();
localS3.start();
The store of a data path is opened while the service runs, and released by shutdown(); the services of a JVM that
share a data path share the one open store, but not what each of them holds in memory, so only one of them is to run at
a time, see One data directory, one service. How the path is laid out is described in
architecture.md; when changes reach the disk, and how a large data path is opened,
in deployment.md.
In-memory mode with initial data
A data path without mode(PERSISTENCE) is initial data: LocalS3 starts from the data of the path, and changes on such
an instance only modify the data in memory, never the disk. Tests that start from the same path share its loaded
metadata and the objects read from it, within a bounded cache; see LOCAL_S3_INITIAL_DATA_CACHE_MAX_BYTES in
deployment.md.
LocalS3 localS3 = LocalS3.builder()
.port(0) // assign a random port
.dataPath("/data")
.build();
localS3.start();
localS3.reset() brings the service back to that initial data, much quicker than restarting it.
Seed the initial buckets and objects
seeder(...) puts the buckets and objects that the service starts with into it, e.g. the fixtures of a test, before it
accepts the first request. A seeder runs again after localS3.reset(), so every test method of a class that shares one
service finds the same fixtures, and the bucket of an object is created if nothing else names it.
LocalS3 localS3 = LocalS3.builder()
.port(0)
.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();
localS3.start();
Unlike initial data, which is a data path that LocalS3 itself wrote, a seeder
writes whatever the application has at hand: files of the classpath, rows of a database, generated content. An object
replaces the one that its key already holds. Spring Boot applications map a directory tree of the classpath with
local-s3.seed.classpath instead of writing a seeder.
Seed a directory tree
LocalS3Seeder.directory(Path) and LocalS3Seeder.classpath(String) map a directory tree onto buckets and objects as
<bucket>/<key>: the file fixtures/s3/uploads/images/logo.png of the location fixtures/s3 becomes the object
images/logo.png of the bucket uploads, with a content type guessed from the key.
LocalS3 localS3 = LocalS3.builder()
.seeder(LocalS3Seeder.classpath("fixtures/s3")) // src/test/resources/fixtures/s3
.seeder(LocalS3Seeder.directory(Path.of("data/s3"))) // a directory of the file system
.build();
- A classpath location is read from every classpath entry that holds it, directories and jars alike, so a jar of test fixtures shared by several modules is seeded too. A jar is found by the entry of the directory, which the jars that Gradle and Maven build have.
- A file directly in the location names no bucket, so it is skipped with a warning, which leaves room for a
READMEor a.gitkeepnext to the buckets. A directory that holds no files creates no bucket. - A location that doesn't exist fails the start of the service with a
FileNotFoundException, rather than starting it without its fixtures. The classpath root is rejected, since it would seed every class of the application.
Require signed requests
By default LocalS3 serves every request, signed or not. credentials(...) enables AWS Signature Version 4
verification with a static access key pair: requests that are unsigned, signed with another key, or whose
signature doesn't match are rejected. Configure your S3 client with the same pair.
LocalS3 localS3 = LocalS3.builder()
.credentials("access-key-id", "secret-access-key")
.build();
localS3.start();
The health check needs no authentication, so probes keep working.
What is verified, besides the Authorization header of SigV4:
- Presigned URLs and browser form uploads, whose signature is in the query or in the fields of the form.
- Temporary credentials of the STS endpoint and the session credentials of
S3 Express One Zone, whose session token travels in
x-amz-security-tokenorx-amz-s3session-token(or the query parameter or form field of the same name). - The service of the credential scope:
s3,s3vectorsors3expressfor an S3 request,sts,kmsands3tablesfor the other APIs on the port. A request routed to an API by its scope is verified for that service, so claiming a scope gains nothing. - SigV4a (
AWS4-ECDSA-P256-SHA256, used by multi-Region access points and some CRT-based clients) and the legacy Signature Version 2 (AWS AKID:signature, orAWSAccessKeyIdandSignaturein a presigned URL) only have their access key checked, not their signature, so that such clients work; a warning is logged the first time.
Look at what is in the service
An embedded service has no window of its own, so GET /_admin/ui serves a built-in console:
a single HTML page that lists the buckets and creates one, walks a bucket by its prefixes, previews or downloads an
object, and uploads files dropped on it or deletes one. A service logs its address when it starts, and endpoint()
names it as well:
localS3.start();
Desktop.getDesktop().browse(URI.create(localS3.endpoint() + "/_admin/ui"));
A service configured with credentials(...) asks for them as HTTP Basic authentication, since a browser can't sign a
request with SigV4. What the console creates, uploads and deletes reaches the
change listeners like every other change, so a fixture dropped on the page is
seen by the application that holds the service.
Presigned URLs
presign(bucket, key, expiration) signs a URL that reads an object for a while, so that whoever holds the URL — a
browser, a teammate an AI agent hands an artifact to, a tool that takes a download link — gets the object without
credentials:
String url = localS3.presign("artifacts", "reports/q1 summary.pdf", Duration.ofMinutes(15));
// http://127.0.0.1:29090/artifacts/reports/q1%20summary.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&...
The fourth argument is the HTTP method that the URL is signed for, GET by default; PUT hands out an upload slot:
String uploadUrl = localS3.presign("artifacts", "input.csv", Duration.ofMinutes(15), "PUT");
- The same signature the service verifies: the URL is path-style, signed with AWS Signature Version 4 for the
credentials of the service, and expires after the given duration — between 1 second and 7 days, as with Amazon S3.
With
s3Api(s3 -> s3.allowedClockSkew(Duration.ZERO))the request time isn't checked, so the URL doesn't expire; see request validation. A request of another method, another key or with an edited signature is answered403. - Without
credentials(...)the service answers unsigned requests, so the plain URL of the object is returned instead, which doesn't expire. - Nothing is touched: neither the bucket nor the object has to exist when a URL is signed, and a URL used after the
object is gone is answered
404 NoSuchKey, exactly as Amazon S3 behaves.
An AWS SDK S3Presigner pointed at localS3.endpoint() signs URLs that the service accepts too; presign(...) is for
the embedding application that has the service at hand and doesn't want to build a second client to share a file.
Serve HTTPS
tls(LocalS3Tls.selfSigned()) generates a certificate for localhost, 127.0.0.1 and ::1 when the service is
built, so that a client that uses HTTPS by default, e.g. DuckDB, connects without a certificate to install:
LocalS3Tls tls = LocalS3Tls.selfSigned(); // or selfSigned("localhost", "s3.local"), for other hosts
LocalS3 localS3 = LocalS3.builder().tls(tls).build();
localS3.start();
// Nothing trusts a certificate that signed itself, so the client is given it:
S3Client s3 = S3Client.builder()
.endpointOverride(URI.create("https://localhost:" + localS3.getPort()))
.httpClient(ApacheHttpClient.builder().tlsTrustManagersProvider(tls::trustManagers).build())
.build();
// For a client outside the JVM, e.g. curl --cacert local-s3.pem or DuckDB's ca_cert_file:
Files.writeString(Path.of("local-s3.pem"), tls.certificateChainPem());
newClientSslContext() returns an SSLContext that trusts the certificate for a client that takes one, e.g.
HttpClient or HttpsURLConnection, and the service logs the certificate in PEM format when it starts. A new
certificate is generated per call, so a client that holds the certificate of one service doesn't trust another. See
Generate a certificate on startup. Where mkcert is installed, the
CA of mkcert issues the certificate instead, so clients that trust that CA, e.g. curl and DuckDB after
mkcert -install, need nothing; see LocalS3 with mkcert.
tls(certPem, keyPem) serves HTTPS with a certificate of your own instead. Each argument is the path of a PEM file, or
the PEM content itself, e.g. read from a secret; tls(Path, Path) takes the files as paths. Create the certificate and
key with mkcert, e.g. mkcert localhost 127.0.0.1, and the clients of the
machine trust them without being given anything:
LocalS3 localS3 = LocalS3.builder()
.tls("localhost+1.pem", "localhost+1-key.pem")
.build();
localS3.start();
// localS3.isTlsEnabled() == true; clients use https://localhost:29090
The key must be an unencrypted PKCS#8 key (-----BEGIN PRIVATE KEY-----), which mkcert writes. A JVM client trusts the
certificate once the CA of mkcert is in its trust store; see HTTPS for that, and for the clients
that need TLS.
Temporary credentials (STS)
LocalS3 answers the STS actions AssumeRole, GetSessionToken and GetCallerIdentity on its own port, so a client
that gets temporary credentials from STS, e.g. an Iceberg REST catalog that vends them to DuckDB, PyIceberg or Spark,
can use LocalS3 as its STS endpoint. Requests signed with the temporary credentials, i.e. with their
x-amz-security-token, X-Amz-Security-Token of a presigned URL, or the x-amz-security-token field of a form upload,
are accepted until the credentials expire.
StsClient sts = StsClient.builder()
.endpointOverride(URI.create("http://localhost:29090"))
.region(Region.US_EAST_1)
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create("access-key-id", "secret-access-key")))
.build();
Credentials credentials = sts.assumeRole(b -> b
.roleArn("arn:aws:iam::123456789012:role/reader")
.roleSessionName("spark")
.durationSeconds(3600)).credentials();
- Stateless: nothing is stored. The session token carries the access key ID, the expiration, the identity and the session policy, authenticated with a key derived from the secret access key, and the temporary secret access key is derived from the token again. The credentials stay valid across restarts, in both modes, as long as the secret access key is the same; changing it revokes all of them.
- No IAM:
RoleArn,RoleSessionNameandDurationSecondsare validated like STS validates them (AssumeRole15 minutes to 12 hours, 1 hour at most when chained from temporary credentials;GetSessionTokenup to 36 hours, not from temporary credentials). Any role ARN is accepted, and the role can do everything the static key pair can; one of the formarn:aws:iam::<account>:role/<name>names the account and the role of the assumed-role ARN. - Session policies: the
PolicyofAssumeRolelimits its credentials, as IAM intersects it with the permissions of the role: an S3 request is allowed if a statement allows it and none denies it, and answered with403 AccessDeniedotherwise. This is how a catalog scopes the credentials of a table to its location, and Lakekeeper checks that it holds. Each operation is authorized as the action that Amazon S3 authorizes it as, e.g.ListObjectsV2ass3:ListBucketonarn:aws:s3:::bucketandUploadPartass3:PutObjectonarn:aws:s3:::bucket/key; a copy needss3:GetObjecton its source as well, and each object ofDeleteObjectsthat isn't allowed is reported as anAccessDeniederror of its own.Action/NotActionandResource/NotResourceare matched with*and?, andConditionis evaluated forStringEquals,StringNotEquals,StringLikeandStringNotLike(with or withoutIfExists) ons3:prefix,s3:delimiter,s3:max-keysands3:VersionId; any other condition is ignored, i.e. met. A policy that isn't one is answered withMalformedPolicyDocument. The requests of S3 Vectors, S3 Tables, the Iceberg REST catalog, KMS and STS, and browser uploads, aren't limited by it, while the admin endpoints are denied to it with403 AccessDenied, since no policy can scope them. Credentials without a policy, ofAssumeRolewithout one or ofGetSessionToken, can do everything the static key pair can. - Without
credentials(...)the endpoint issues credentials as well, and every request is accepted as before, so a catalog configured with an STS endpoint works with an unauthenticated LocalS3 too. A request that carries a session token of LocalS3 is still limited by its session policy. - Errors: an STS request is answered with the errors of STS, e.g.
InvalidClientTokenId; an S3 request signed with a forged or foreign token with400 InvalidToken, and with an expired one with400 ExpiredToken.
Envelope encryption (KMS)
LocalS3 answers the KMS actions GenerateDataKey, GenerateDataKeyWithoutPlaintext, Encrypt, Decrypt,
DescribeKey and GenerateRandom on its own port, so a client that encrypts objects itself and wraps the data key
with KMS, e.g. the Amazon S3 Encryption Client, needs no second endpoint.
KmsClient kms = KmsClient.builder()
.endpointOverride(URI.create("http://localhost:29090"))
.region(Region.US_EAST_1)
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create("access-key-id", "secret-access-key")))
.build();
GenerateDataKeyResponse dataKey = kms.generateDataKey(b -> b
.keyId("alias/local")
.keySpec(DataKeySpec.AES_256)
.encryptionContext(Map.of("bucket", "warehouse")));
// Encrypt the object with dataKey.plaintext(), store dataKey.ciphertextBlob() beside it, and unwrap it later:
SdkBytes plaintext = kms.decrypt(b -> b
.ciphertextBlob(dataKey.ciphertextBlob())
.encryptionContext(Map.of("bucket", "warehouse"))).plaintext();
- Nothing is kept secret: a ciphertext blob is the plaintext in an envelope that anyone can unpack, and there is no key material. A blob written by LocalS3 protects nothing; it is for tests and local development only.
- Stateless and keyless: no key is stored, so every key ID, alias or ARN works,
DescribeKeydescribes it, and a blob is read back after a restart, in both modes. - A faithful round trip: a blob unwraps to the plaintext it was wrapped from, bound to its key ID and encryption
context; another context answers
InvalidCiphertextExceptionand anotherKeyIdanswersIncorrectKeyException, see semantics.md. - This is separate from the
x-amz-server-side-encryptionheaders of SSE-KMS, which LocalS3 stores and echoes without calling the endpoint.
Listen to bucket and object changes
changeListener subscribes an S3ChangeListener, which receives an S3Change whenever a bucket or an object
changes. semantics.md lists the changes and when they are delivered.
LocalS3 localS3 = LocalS3.builder()
.changeListener(change ->
System.out.println(change.type() + " " + change.bucketName() + " " + change.key()))
.build();
localS3.start();
The listeners run synchronously on the thread that made the change by default, after the locks are released but
before the operation returns: the time a listener takes adds to the latency of the request that made the change.
This suits a listener that must see the change before the client gets its response, e.g. Spring's
@TransactionalEventListener. A listener that takes longer than 1 second on that thread is logged once as a WARN,
naming the listener. Pass an executor to deliver changes asynchronously, so that slow listeners don't hold up request
handling; a single-threaded executor keeps the changes in order.
ExecutorService executor = Executors.newSingleThreadExecutor();
LocalS3 localS3 = LocalS3.builder()
.events(events -> events.executor(executor)
.listener(change -> index("s3://" + change.bucketName() + "/" + change.key())))
.build();
localS3.start();
Inject faults
faults() makes a running service fail the way Amazon S3 does now and then — 503 SlowDown, 500 InternalError, a
slow response, a body that stops halfway — so that the retries and fallbacks of a client can be tested without a proxy
in front of the service. A Fault matches requests by their S3 semantics: the operation, the bucket, the key, the HTTP
method, and which of the matching requests it applies to.
import static com.robothy.s3.rest.fault.FaultMatcher.op;
localS3.faults().add(Fault.when(op("UploadPart").bucket("lake").times(2))
.respond(S3ErrorCode.SlowDown)); // the first 2 UploadPart of bucket "lake"
localS3.faults().add(Fault.when(op("GetObject").keyPrefix("data/"))
.delay(Duration.ofMillis(800))); // every GetObject under data/
localS3.faults().add(Fault.when(op("GetObject")).probability(0.05)
.resetConnectionAfterBytes(1024)); // 5% of the downloads break after 1 KiB
localS3.faults().add(Fault.when(op("PutObject").nth(3))
.closeConnection()); // the 3rd PutObject gets no response at all
localS3.faults().clear();
The conditions of a FaultMatcher:
| Condition | Matches |
|---|---|
op("GetObject", "HeadObject") | these operations, by the names that the request statistics report, see /_admin/stats |
anyOp() | every operation |
bucket("lake") | requests of this bucket |
key("a.txt"), keyPrefix("data/") | requests of this object key, or of keys with this prefix |
method("PUT") | requests of this HTTP method |
skip(n), times(n), nth(n) | of the matching requests, counted from when the fault was added: not the first n; only n of them; only the nth |
The actions of a Fault, of which delay and throttle combine with the others:
| Action | Effect |
|---|---|
respond(S3ErrorCode.SlowDown), respond(503, "SlowDown") | answers with this error instead of performing the operation; an S3 Vectors request, which sends JSON, gets the error as JSON |
delay(Duration) | waits before the operation is performed, so a client that times out meanwhile still finds it done, as with Amazon S3 |
throttle(bytesPerSecond) | sends the response at most this fast |
resetConnectionAfterBytes(n) | performs the operation, sends the headers and the first n bytes of the body, then closes the connection |
closeConnection() | closes the connection without performing the operation or answering |
probability(p) applies a fault to that share of the requests it matches. When several faults match a request, the
one added first that applies to it wins; every matching fault counts the request for its skip, times and nth
all the same. fault.injected() tells how often a fault was applied.
- The health check, the admin endpoints and the console are never failed, so that a probe of the service isn't either.
reset()removes the faults, like it clears the request statistics.- A delay holds the thread that handles the request, a virtual thread by default.
Configure from the environment
fromEnvironment() applies the environment variables of the Docker image, e.g. LOCAL_S3_MODE or AWS_BUCKETS, that
are set; see deployment.md. The credentials come from LOCAL_S3_ACCESS_KEY_ID and
LOCAL_S3_SECRET_ACCESS_KEY; AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, which an IDE or an application inherits
from the shell of the developer as client credentials, are read only with LOCAL_S3_CREDENTIALS_FROM_AWS_ENV=true.
Settings grouped by domain
The settings a service is usually built with — its address, its mode and data directory, its buckets, its credentials — are methods of the builder itself. The settings that only some services tune are grouped behind one method per domain, which takes the settings of that domain and applies them, so the rarely used knobs stay out of the way:
| Domain | Method | What it configures |
|---|---|---|
| Storage | storage(storage -> ...) | mode, dataPath, persistencePolicy, maxInMemoryBytes, initialDataCacheEnabled |
| HTTP server | netty(netty -> ...) | parentEventGroupThreadNum, childEventGroupThreadNum, s3ExecutorThreadNum, virtualThreads, daemonThreads, maxRequestBodySize, requestBodyFileThreshold, maxRequestHeaderSize, idleConnectionTimeoutSeconds, registerShutdownHook, requestRecorder |
| S3 API | s3Api(s3 -> ...) | virtualHostDomains, compositeMultipartEtags, acceptChunkedUploads, allowedClockSkew; see request validation |
| Change events | events(events -> ...) | listener(S3ChangeListener), executor(Executor) |
| Lifecycle | lifecycle(lifecycle -> ...) | applyEvery(Duration); see lifecycle configuration |
| HTTPS | tls(tls -> ...) | certificate(...), selfSigned(...), required(...) |
| Static websites | website(website -> ...) | enabled, allBuckets, indexDocument, errorDocument, settings(LocalS3Website) |
| Default CORS rule | defaultCors(cors -> ...) | allowedOrigins, allowedMethods, allowedHeaders, exposeHeaders, maxAgeSeconds, settings(LocalS3Cors); see CORS |
| Iceberg REST catalog | icebergCatalog(iceberg -> ...) | enabled, warehouse, createWarehouseBucket, credentialVending, uniqueTableLocation, settings(LocalS3IcebergCatalog) |
| Optional features | features(f -> ...) | vector, s3Tables, kms, sts, console, settings(LocalS3Features); see Turn features off |
LocalS3 localS3 = LocalS3.builder()
.port(29090)
.storage(storage -> storage.mode(LocalS3Mode.PERSISTENCE)
.dataPath("/tmp/local-s3")
.persistencePolicy(PersistencePolicy.FAST))
.netty(netty -> netty.childEventGroupThreadNum(8)
.maxRequestBodySize(64 * 1024 * 1024)
.idleConnectionTimeoutSeconds(30))
.s3Api(s3 -> s3.virtualHostDomains("s3.local"))
.tls(tls -> tls.selfSigned().required(true))
.website(website -> website.allBuckets(true).indexDocument("home.html"))
.icebergCatalog(iceberg -> iceberg.warehouse("s3://lakehouse/"))
.build();
localS3.start();
The settings every service is built with stay methods of the builder itself: bindHost, acceptFromAnyHost, port,
mode, dataPath, buckets, credentials, seeder, changeListener and fromEnvironment. Each domain also keeps
the one-liner that turns it on with its defaults: tls(certPem, keyPem), tls(LocalS3Tls), website(true) and
icebergCatalog(true). Entering icebergCatalog(iceberg -> ...) turns the catalog on, since configuring one is asking
for one.
A settings object writes through to the builder as it is called, so it must not be kept beyond the call.
Turn features off
Besides the S3 API, a service serves the S3 Vectors API, the S3 Tables API, an AWS KMS endpoint, an AWS STS endpoint,
the console and the MCP server. They are all on by default but
the MCP server, which mcp(true) turns on; an application that embeds LocalS3 as a plain S3 server can turn off the
ones it doesn't need, which leaves their routes out of the router altogether. The
Spring Boot starter is the exception: it serves S3 and the console alone unless
local-s3.features.* turns the others on. Their requests
are then answered by the S3 API, like a request to a bucket: GET /_admin/ui answers 404 NoSuchBucket, and a
POST / of STS or KMS answers 400 RequestIsNotMultiPartContent, as a form upload that isn't one.
LocalS3 localS3 = LocalS3.builder()
.features(f -> f.vector(false).s3Tables(false).kms(false).sts(false).console(false))
.build();
| Feature | Builder | Variable | Spring Boot property, false by default except console |
|---|---|---|---|
| S3 Vectors API | vector(false) | LOCAL_S3_FEATURES_VECTOR=false | local-s3.features.vector=true |
| S3 Tables API, with the Iceberg REST catalog of its table buckets | s3Tables(false) | LOCAL_S3_FEATURES_S3_TABLES=false | local-s3.features.s3-tables=true |
| AWS KMS | kms(false) | LOCAL_S3_FEATURES_KMS=false | local-s3.features.kms=true |
| AWS STS | sts(false) | LOCAL_S3_FEATURES_STS=false | local-s3.features.sts=true |
Console at /_admin/ui | console(false) | LOCAL_S3_FEATURES_CONSOLE=false | local-s3.features.console=false |
MCP server at /_mcp, off by default | mcp(true) | LOCAL_S3_FEATURES_MCP=true | local-s3.features.mcp=true |
Only what is served changes: the data of a feature that is turned off is kept, and served again once it is turned
back on. Turning STS off removes AssumeRole and GetSessionToken, while the temporary credentials that the Iceberg
REST catalog vends and that CreateSession issues are still accepted. The Iceberg REST catalog, with its remote
signing route /iceberg/v1/aws/s3/sign, and the static website hosting have switches of their own,
icebergCatalog(...), off by default, and website(...).
Start faster with an AOT cache
Most of the time that the first service of a JVM takes to start goes to loading classes. On Java 25, an AOT cache of the application loads and links them ahead of time: create it with one training run of the application, then start it with the cache.
java -XX:AOTCacheOutput=app.aot -cp app.jar:local-s3-rest.jar:... com.example.App # training run; writes app.aot on exit
java -XX:AOTCache=app.aot -cp app.jar:local-s3-rest.jar:... com.example.App
With a classpath of jars, e.g. an IDE plugin or an application started from its jars, the first
LocalS3.builder().port(0).build().start() takes about 65 ms instead of 230 ms on an Apple Silicon laptop.
The JVM only uses the cache with the same JDK, the same classpath and the same object header setting, e.g.
-XX:+UseCompactObjectHeaders; otherwise it warns and starts without it. Java 25 can't create a cache for a classpath
that has a non-empty directory on it (Error: non-empty directory), which rules out the test JVMs of Gradle and Maven,
whose classpath has build/classes or target/classes on it. For a Spring Boot executable jar, extract it first, see
the CDS and AOT cache sections of the Spring Boot documentation.
JetBrains IDEs
A plugin of IntelliJ IDEA, PyCharm, DataGrip or another JetBrains IDE can embed LocalS3 as the S3 service of the IDE, e.g. the default S3 of DuckDB, started with the IDE and stopped when it exits. It uses the Java API, and what follows holds for any application that keeps a service for its whole run. Such a service lives far longer than one of a test, shares its JVM with the IDE, and holds data that the user expects to find again, so four things need a decision: how many services open the data directory, the persistence policy, the order of the shutdown, and the threads.
One data directory, one service
The projects that are open in an IDE share its JVM, so the service belongs to the application rather than to a
project: a project-level service would start one per open window. An application-level service that implements
Disposable has the lifecycle that LocalS3 needs:
@Service(Service.Level.APP)
public final class LocalS3Service implements Disposable {
private final LocalS3 localS3 = LocalS3.builder()
.port(29090) // fixed, so that the S3 secrets of clients stay valid
.storage(storage -> storage.mode(LocalS3Mode.PERSISTENCE)
.dataPath(Path.of(PathManager.getSystemPath(), "local-s3").toString())
.persistencePolicy(PersistencePolicy.FAST))
.netty(netty -> netty.registerShutdownHook(false)) // the IDE stops the service, see below
.build();
public synchronized String endpoint() {
if (!localS3.isRunning()) {
localS3.start(); // on first use, not while the IDE starts
}
return localS3.endpoint();
}
@Override
public void dispose() {
localS3.close();
}
}
A data directory is opened when a service starts, not when it is built, and released when it is shut down. While it is open, it is held by that one service:
- Another process that opens it, e.g. a second IDE with the same setting, or a test run that is given the directory
as its data path or as its initial data, fails to start with
MVStoreException: The file is locked. Give each IDE a directory of its own, e.g. underPathManager.getSystemPath(), and point other processes at the endpoint of the service rather than at its directory. - Another service of the same JVM opens the same store, which is reference-counted, but loads the buckets and
objects of the directory into memory for itself when it starts. The two don't see each other's changes: an object
one of them puts is
404 NoSuchKeyfor the other, an object one of them deletes is500 InternalErrorfor the other, since its content is gone, and a change of the settings of a bucket, e.g. its tags, is lost when the other one next writes the bucket. Never run twoPERSISTENCEservices over one directory at the same time: shut one down before the next one starts, e.g. when the user changes a setting. A second service with another persistence policy is rejected with anIllegalStateExceptionwhen it starts. - The port is taken the same way: a second IDE that embeds LocalS3 on port
29090gets aBindExceptionfromstart(). Make the port a setting of the plugin, and report the failure to the user.
Persistence policy
Use PersistencePolicy.FAST, as the example does, unless the data can't be built again. A FAST service commits its
changes in the background, at most a second after they are made, and everything that is left when it is shut down;
DURABLE, the default of LocalS3Builder, commits every change before it answers the request. FAST loads the data
that a user or a tool writes into the IDE's service, e.g. the Parquet files of a DuckDB COPY, faster, and keeps
buckets.mvstore far smaller while it runs. What it risks is the changes of the last second when the IDE is killed or
crashes; an IDE that exits normally shuts the service down and loses nothing. See
Persistence policy for the numbers.
An IN_MEMORY service suits a plugin whose data is scratch, but its objects take up to a quarter of the max heap by
default, i.e. a quarter of the IDE's heap. Give it a limit of its own, e.g.
storage(storage -> storage.maxInMemoryBytes(256L * 1024 * 1024)), beyond which uploads are answered 507 InsufficientStorage rather than taking memory from the IDE.
Stopping with the IDE
close(), i.e. shutdown(), stops the service in this order:
- The listening socket is closed, so no connection is accepted anymore.
- The requests in flight get up to 5 seconds to finish and write their responses. Whatever still runs after that is interrupted, and its response cut off.
- The data directory is released: what a
FASTservice hasn't committed yet is committed,buckets.mvstoreis compacted to the size of the metadata it holds, and the file lock is given up, so that another service or process can open the directory.
What follows from that order:
- Close the service before what it uses. An executor given to
events(events -> events.executor(...))is to be shut down afterclose(), and a change listener that calls into the IDE, e.g. to refresh a view, runs untilclose()has returned.dispose()of the service that holds LocalS3 is the place forclose(), before the disposal of anything its listeners call. - Turn the shutdown hook off. A started service registers a JVM shutdown hook of its own by default, which stops it
when the JVM exits. The IDE runs an orderly shutdown of its own, which disposes the service, and a hook would stop the
service in parallel with it, possibly before the parts of the plugin that still use it. With
netty(netty -> netty.registerShutdownHook(false))the IDE alone stops the service. An IDE that is killed, or crashes, then leaves the directory as a killed process does: aFASTservice loses at most the last second, aDURABLEone nothing, and the file is compacted the next time the service is shut down. - Don't block the UI thread.
close()takes up to the 5 seconds of step 2, plus the compaction of step 3. A plugin that stops or restarts the service on an action of the user, e.g. after a change of its port, does that on a background thread. - Restart in order. A new service over the same directory is started only once the old one is shut down, see
One data directory, one service. An
IN_MEMORYservice keeps its data across such a restart withnewService.takeOverDataOf(oldService), called after the old one is shut down and before the new one is started.
Threads
The event loops of the service, and its request threads if they aren't virtual ones, are daemon threads by default;
virtual threads always are. A service that is never shut down therefore doesn't keep the JVM alive, and doesn't delay
the exit of the IDE. Keep that default in an IDE: netty(netty -> netty.daemonThreads(false)) is for a main method
that starts the service and returns, which only non-daemon threads keep running.
The other side of it is that nothing waits for a daemon thread when the JVM exits: requests still running are dropped
on the spot, and a service that wasn't shut down leaves its data directory as a killed process does. That is why
dispose() must call close(), rather than leave the service to the exit of the JVM.
LocalS3 runs on Netty 4.1 or 4.2, but needs every Netty module on the same version, which a plugin bundles. A single
module of the other line that the plugin sees next to them, e.g. one that the IDE ships, fails at runtime, since Netty
4.2 moved classes between modules;
io.netty.util.Version.identify() lists the versions that the plugin actually runs with.
The IDE inherits the AWS_* variables of the shell it was launched from; a plugin that builds its service with
fromEnvironment() doesn't take them for the credentials of the service, see
Configure from the environment. The console at
endpoint() + "/_admin/ui" gives the user a view of what the service holds, e.g. from an action that opens it in the
browser.
Expiring old data
A service that runs as long as the IDE never applies the lifecycle configurations of its buckets by itself, so the
scratch data that a rule would expire, e.g. the intermediate files of a query or of an AI agent, piles up in the data
directory. lifecycle(lifecycle -> lifecycle.applyEvery(Duration.ofHours(1))) applies them once the service has
started and every hour after, on a daemon thread that close() stops; see
lifecycle configuration.
Spring Boot
local-s3-spring-boot-starter embeds LocalS3 in a Spring Boot 3/4 application, configured by local-s3.* properties,
and, when the application has the AWS SDK, which is an optional dependency, defines an S3Client, an S3AsyncClient
and an S3Presigner that point at it. local-s3.seed.classpath names a directory tree of the classpath that the
service starts with, as <bucket>/<key>. The objects of the default IN_MEMORY mode take up to a quarter of the
heap that the service shares with the application; set local-s3.in-memory.max-size, or local-s3.mode=persistence
with a local-s3.data-path for an application that receives large files, see
Memory. local-s3.website.* configures
static website hosting: enabled, all-buckets, index-document and
error-document. local-s3.cors.* configures the default CORS rule of the buckets without one
of their own: allowed-origins, allowed-methods, allowed-headers, expose-headers and max-age. Of the optional features, the
starter serves the console alone by default; local-s3.features.vector, s3-tables, kms and sts turn the others
on. See its README. The starter is on by default, so declare it for development and tests only (Gradle developmentOnly or
testAndDevelopmentOnly, a Maven test scope or profile). If the production jar includes it, the application
starts a local service. Its clients still reach the endpoint the application is configured with, e.g.
spring.cloud.aws.s3.endpoint or AWS_ENDPOINT_URL: the starter then defines no clients, and logs a warning; see
another S3 endpoint. Without such an endpoint, its
S3Client points at the local service.
In a @SpringBootTest, the service listens on a random free port unless local-s3.port is set, so that the test
contexts that Spring caches side by side don't compete for port 29090; the client beans of the starter, and the
${local.s3.endpoint} placeholder, name the port it listens on. @AutoConfigureLocalS3 also resets the data after each
test method.
Events of the Spring Boot starter
The starter publishes every S3Change that the service commits to the application context, where @EventListener and
@TransactionalEventListener methods receive it (local-s3.events.enabled=false turns it off).
local-s3.events.executor chooses the thread a listener runs on:
local-s3.events.executor | Where the listeners run | Order | The S3 response waits for the listeners |
|---|---|---|---|
direct (default) | the thread that made the change, e.g. the one handling the PutObject request | committed order | yes |
virtual | one virtual thread of the starter, local-s3-events-0 | committed order | no |
application | Spring Boot's applicationTaskExecutor (or the application's only Executor bean) | none: changes of the same key may be handled concurrently, a delete before its create | no |
With direct, the time a listener takes adds to the latency of the request that made the change: a client that
uploads a file waits until its listener has parsed it, stored it or called a model. That suits tests that assert on
what a listener did right after the upload, but not a listener that does real work. Choose virtual for that; choose
application only when the listeners don't depend on the order of the changes, e.g. each handles its own key once,
and to share the pool, its size and its metrics with the @Async methods of the application. The executor applies to
every listener of the service, the listener(...) of a LocalS3BuilderCustomizer included; a customizer that calls
events(events -> events.executor(...)) overrides it. When the context closes, the service stops first, then virtual
delivers the changes still queued, waiting up to 10 seconds.
A complete "process each upload" listener:
local-s3:
buckets: [uploads]
events:
executor: virtual # the PutObject response doesn't wait for the processing
@Component
class UploadProcessor {
private final S3Client s3;
private final DocumentRepository documents;
UploadProcessor(S3Client s3, DocumentRepository documents) {
this.s3 = s3;
this.documents = documents;
}
@EventListener(condition = "#change.type() == T(com.robothy.s3.core.event.S3ChangeType).OBJECT_CREATED "
+ "and #change.bucketName() == 'uploads' and #change.contentType() == 'application/pdf'")
void onUpload(S3Change change) {
// The content type and the x-amz-meta-* of the change are those of the committed version, so routing needs no
// HeadObject. The content is read back: the version the change names in a versioned bucket, otherwise the current
// object, which a later put may already have replaced with an asynchronous executor.
String owner = change.userMetadata().get("owner");
try (ResponseInputStream<GetObjectResponse> content = s3.getObject(request -> request
.bucket(change.bucketName()).key(change.key()).versionId(change.versionId()))) {
documents.save(Document.parse(change.key(), owner, content));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
An exception thrown by a listener is logged, whichever the executor; it doesn't fail the request, and the change isn't delivered again. A listener that has to retry does it itself.
@TransactionalEventListener with an asynchronous executor. A transactional listener is bound to the transaction
of the thread that publishes the event. With direct, a change that the application makes through
localS3.getS3Manager() in a @Transactional method is published on that thread, so the listener runs once the
transaction commits, and not at all if it rolls back. With virtual or application, the change is published on
another thread, which has no transaction: by default a @TransactionalEventListener ignores the event entirely, and
with fallbackExecution = true it runs right away, whether the transaction of the change commits or rolls back. Use
@EventListener with those executors, or keep direct for the changes that have to follow a transaction. A request of a
client is never part of a transaction of the application, so this only concerns changes made through the Java API.
Native images
An application that embeds LocalS3, with the starter or with the Java API, can be built into a GraalVM native
executable, e.g. with ./gradlew nativeCompile of Spring Boot, without metadata of its own: local-s3-datatypes,
local-s3-core and local-s3-rest each carry a META-INF/native-image/io.github.robothy/<module>/reachability-metadata.json,
generated from their classes when they are built, that registers what LocalS3 reaches reflectively — the classes that
Jackson reads and writes, the JDK collections that a PERSISTENCE service reads its metadata back into, and the page of
the console. A Spring Boot 4 application with the starter, built with GraalVM 25, answers the S3, S3 Vectors, S3 Tables,
STS, KMS and Iceberg REST requests that the JVM answers, and so does the executable jar built with native-image alone,
which reads back the data directory of a PERSISTENCE service after a restart.
JUnit 5
local-s3-jupiter provides the annotation @LocalS3, which launches S3 services for your tests.
<dependency>
<groupId>org.mvnsearch</groupId>
<artifactId>local-s3-jupiter</artifactId>
<version>last_version</version>
<scope>test</scope>
</dependency>
When you annotate test classes or test methods with it, the LocalS3 extension injects instances of the following parameter types into test methods and lifecycle methods:
S3Client,S3AsyncClientandS3PresignerS3TransferManager, when the test bringssoftware.amazon.awssdk:s3-transfer-managerS3VectorsClientandS3TablesClientLocalS3Endpointcom.robothy.s3.rest.LocalS3, the service itself
The S3AsyncClient, the S3Presigner and the S3TransferManager are closed when the context they were injected into
ends. These are the clients that local-s3-spring-boot-starter defines as beans, so a test moves between the two
without changing what it asks for.
Example 1: inject an S3Client into a test method.
class AppTest {
@Test
@LocalS3
void test(S3Client client) {
client.createBucket(b -> b.bucket("my-bucket"));
}
}
Example 2: inject an S3VectorsClient into a test method.
class AppTest {
@Test
@LocalS3
void test(S3VectorsClient vectorsClient) {
vectorsClient.createVectorBucket(b -> b.vectorBucketName("my-vector-bucket"));
}
}
Example 3: inject instances into JUnit 5 lifecycle methods.
@LocalS3
class AppTest {
@BeforeAll
static void beforeAll(S3Client client) {
client.createBucket(b -> b.bucket("my-bucket"));
}
@Test
void test(S3Client client) {
client.headBucket(b -> b.bucket("my-bucket"));
}
@AfterAll
static void afterAll(S3Client client) {
client.deleteBucket(b -> b.bucket("my-bucket"));
}
}
buckets creates the buckets before the test runs, and versionedBuckets creates them with versioning enabled, so a
test of a bucket that has versioning enabled in production needs no PutBucketVersioning:
@LocalS3(buckets = "plain", versionedBuckets = "audit"). LocalS3.builder().versionedBuckets("audit") does the same
for an embedded service.
seed puts directory trees into the service, and seeders lists
LocalS3Seeder classes with a no-args constructor, applied after the trees.
Both run when the service starts and again after reset():
@LocalS3(seed = "classpath:fixtures/s3", seeders = UsersSeeder.class)
class AppTest {
@Test
void test(S3Client s3) {
// src/test/resources/fixtures/s3/uploads/hello.txt
s3.getObject(b -> b.bucket("uploads").key("hello.txt"));
}
}
A value of seed without the classpath: prefix names a directory of the file system, relative to the working
directory of the tests.
Example 4: inject the service itself, to use what it offers besides the S3 API: reset() between the tests of a shared
service, applyLifecycle(Instant) to expire objects at a later time, or an S3ChangeListener to observe the changes
that the code under test makes. The service shares its simple name with the annotation, so one of the two is written
with its package; the extension shuts the service down, so a test must not.
@LocalS3(buckets = "my-bucket")
class AppTest {
@AfterEach
void afterEach(com.robothy.s3.rest.LocalS3 localS3) {
localS3.reset();
}
@Test
void test(com.robothy.s3.rest.LocalS3 localS3, S3Client s3) {
List<S3Change> changes = new CopyOnWriteArrayList<>();
localS3.getS3Manager().addChangeListener(changes::add);
s3.putObject(b -> b.bucket("my-bucket").key("a.txt"), RequestBody.fromString("a"));
// changes holds the PutObject of a.txt
localS3.applyLifecycle(Instant.now().plus(Duration.ofDays(31)));
}
}
The attributes of @LocalS3 configure the service like the builder does: port, mode, dataPath or
dataPathSupplier, buckets, versionedBuckets, seed, seeders, initialDataCacheEnabled, maxInMemoryBytes (e.g. "512m"),
compositeMultipartEtags, acceptChunkedUploads, virtualHostDomains, and accessKey with secretKey.
Signed requests
accessKey and secretKey enable signature verification, and sign the clients that the extension injects with the
same pair, so a test of signed requests needs no hand-built service:
@LocalS3(accessKey = "access-key-id", secretKey = "secret-access-key")
class AppTest {
@Test
void theInjectedClientIsSigned(S3Client s3) {
s3.createBucket(request -> request.bucket("my-bucket"));
}
}
Both attributes must be set together; setting neither, which is the default, leaves verification off and injects an anonymous client.
@LocalS3 on test classes and test methods
If @LocalS3 is on a test class, the extension creates a service shared by all test methods in the class,
including the test methods of its @Nested classes, and shuts it down in the "after all" callback.
If @LocalS3 is on a test method, the extension creates an exclusive service for the method and shuts it down in the
"after each" callback.
A @Nested class that is annotated itself gets a service of its own, and a test is always given the innermost service:
the one of its method, then the one of its class, then the one of an enclosing class. Services are kept per test class
and method rather than per thread, so they work the same when tests run in parallel.
Testcontainers
local-s3-testcontainers runs the Docker image with Testcontainers.
<dependency>
<groupId>org.mvnsearch</groupId>
<artifactId>local-s3-testcontainers</artifactId>
<version>last_version</version>
<scope>test</scope>
</dependency>
@Testcontainers
class AppTest {
@Container
LocalS3Container localS3 = new LocalS3Container("latest")
.withMode(LocalS3Container.Mode.IN_MEMORY)
.withCredentials("local-s3", "local-s3-secret")
.withBuckets("my-bucket");
@Test
void s3Operations() {
S3Client s3 = S3Client.builder()
.endpointOverride(localS3.getEndpointUri())
.region(Region.US_EAST_1)
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(localS3.getAccessKey(), localS3.getSecretKey())))
.forcePathStyle(true)
.build();
s3.putObject(b -> b.bucket("my-bucket").key("test.txt"), RequestBody.fromString("Hello World"));
}
@Test
void s3VectorOperations() {
S3VectorsClient vectors = S3VectorsClient.builder()
.endpointOverride(localS3.getEndpointUri())
.region(Region.US_EAST_1)
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(localS3.getAccessKey(), localS3.getSecretKey())))
.build();
vectors.createVectorBucket(b -> b.vectorBucketName("my-vector-bucket"));
vectors.createIndex(b -> b
.vectorBucketName("my-vector-bucket")
.indexName("my-index")
.dimension(128)
.dataType(DataType.FLOAT32)
.distanceMetric(DistanceMetric.COSINE));
}
}
The port
Docker allocates the host port when the container starts, and getPort() returns it, so that test classes which
run at the same time can't pick the same port. getEndpoint() and getEndpointUri() assemble the URL from it, which
is what endpointOverride takes; getPort() before the container has started fails with an IllegalStateException
rather than answering a port nothing listens on.
withHttpPort(29090) binds a port of your own instead, for the cases that need one known in advance, e.g. a URL in a
configuration file; that port has to be free when the container starts. withRandomHttpPort() goes back to letting
Docker choose.
Configuration
Every setting of the image is reachable. The methods below set the environment
variables of the same meaning, and anything they don't cover is withEnv(name, value).
| Method | Sets |
|---|---|
withMode(Mode) | LOCAL_S3_MODE: IN_MEMORY or PERSISTENCE |
withDataPath(String | Path) | binds a host directory at /data |
withPersistencePolicy(PersistencePolicy) | LOCAL_S3_PERSISTENCE_POLICY: DURABLE or FAST |
withInMemoryMaxBytes("512m") | LOCAL_S3_IN_MEMORY_MAX_BYTES |
withCredentials(accessKey, secretKey) | AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY; getAccessKey() and getSecretKey() read them back |
withBuckets("one", "two") | AWS_BUCKETS: the buckets to create on startup |
withVersionedBuckets("audit") | AWS_BUCKETS as audit:versioned: the buckets to create with versioning enabled, besides those of withBuckets |
withIcebergCatalog(true) | LOCAL_S3_ICEBERG_CATALOG: serve /iceberg/v1 |
withIcebergWarehouse("s3://warehouse/") | LOCAL_S3_ICEBERG_WAREHOUSE |
withVirtualHostDomains("s3", "s3.local") | LOCAL_S3_VIRTUAL_HOST_DOMAINS |
withSelfSignedTls(hosts...) | LOCAL_S3_TLS_SELF_SIGNED; getEndpoint() becomes an https:// URL |
withTlsRequired(true) | LOCAL_S3_TLS_REQUIRED: refuse plain HTTP |
withWebsite(false) / withWebsiteAllBuckets(true) | LOCAL_S3_WEBSITE, LOCAL_S3_WEBSITE_ALL_BUCKETS |
Without withCredentials(...) the service accepts unsigned requests, so a client may sign with anything, and
getAccessKey() answers null.
An image of a private registry is run by the DockerImageName constructor:
new LocalS3Container(DockerImageName.parse("registry.internal/local-s3:2.5.0")
.asCompatibleSubstituteFor(LocalS3Container.IMAGE_NAME));
To wait for the health check rather than the startup log message, see deployment.md.
@ServiceConnection with Spring Cloud AWS
In a Spring Boot test, @ServiceConnection points the clients that Spring Cloud AWS
auto-configures, e.g. S3Client, S3Template and S3Presigner, at the container, as it does for LocalStack. No
property of the application is needed: local-s3-testcontainers registers a ConnectionDetailsFactory that answers
with the AwsConnectionDetails of the container. It takes effect where the test classpath has
spring-boot-testcontainers and a Spring Cloud AWS starter, e.g. spring-cloud-aws-starter-s3: 4.x with Spring Boot 4,
3.4 with Spring Boot 3.5.
@SpringBootTest
@Testcontainers
class UploadTest {
@Container
@ServiceConnection
static LocalS3Container localS3 = new LocalS3Container("latest")
.withBuckets("uploads");
@Autowired
S3Template s3Template;
@Test
void uploads() {
s3Template.upload("uploads", "hello.txt", new ByteArrayInputStream("Hello".getBytes()));
}
}
The container can be a @Bean of a @TestConfiguration instead, which Spring starts and stops, and which needs no
@Testcontainers, as in the quick start:
@Bean
@ServiceConnection
LocalS3Container localS3() {
return new LocalS3Container("latest").withBuckets("uploads");
}
AwsConnectionDetails | Value |
|---|---|
| endpoint | getEndpoint() with the host resolved to an IP address, e.g. http://127.0.0.1:32773 |
| region | us-east-1 |
| access key, secret key | those of withCredentials(...); local-s3 for both when the container accepts unsigned requests |
The endpoint is an IP address because the AWS SDK addresses buckets by path on an IP address, and by host name, e.g.
uploads.localhost, on anything else, which the service doesn't serve unless configured with
withVirtualHostDomains(...); so no spring.cloud.aws.s3.path-style-access-enabled is needed either.
Docker Compose with Spring Cloud AWS
An application that runs LocalS3 as a service of its compose.yaml, rather than embedding it, gets the same service
connection from Spring Boot's Docker Compose support:
local-s3-testcontainers registers a DockerComposeConnectionDetailsFactory too, so the clients of Spring Cloud AWS
reach the service with no property of the application.
# compose.yaml
services:
local-s3:
image: linuxchina/local-s3
ports:
- "29090"
dependencies {
implementation("io.awspring.cloud:spring-cloud-aws-starter-s3")
developmentOnly("org.springframework.boot:spring-boot-docker-compose")
developmentOnly("org.mvnsearch:local-s3-testcontainers:<version>")
}
It matches a service of the image linuxchina/local-s3; a service of another image, e.g. of a private registry, is
matched by the label org.springframework.boot.service-connection: linuxchina/local-s3. The connection details follow
the environment of the service:
AwsConnectionDetails | Value |
|---|---|
| endpoint | the host port mapped to LOCAL_S3_PORT (29090 by default), with the host resolved to an IP address; https:// where LOCAL_S3_TLS_CERT or LOCAL_S3_TLS_SELF_SIGNED is set |
| region | us-east-1 |
| access key, secret key | LOCAL_S3_ACCESS_KEY_ID and LOCAL_S3_SECRET_ACCESS_KEY, or AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY (the image sets LOCAL_S3_CREDENTIALS_FROM_AWS_ENV=true); local-s3 for both when the service accepts unsigned requests |