diff --git a/README.md b/README.md
index 9a4cc0c..37dec4d 100644
--- a/README.md
+++ b/README.md
@@ -11,5 +11,6 @@ Runnable companion code for the Spring AI articles on [ankurm.com](https://ankur
| [`mcp-secure/`](mcp-secure) | The mcp-server article's order-lookup tools behind a real OAuth2 resource server: JWT validation, one scope per tool via `@PreAuthorize`, unauthenticated tool discovery rejected outright, and every call audit-logged through MDC -- denials included. Spring Boot 4.1.1, Spring AI 2.0.1, Spring Security 7.1.1, Java 25. | [Securing an MCP Server with Spring Security 7](https://ankurm.com/spring-ai-2-0-mcp-server-security/) |
| [`tool-calling/`](tool-calling) | `@Tool` methods, `ToolCallingAdvisor` (the advisor-layer replacement for Spring AI 1.x's per-model tool loop), `returnDirect`, `ToolContext`, and `ToolSearchToolCallingAdvisor` for progressive disclosure across a 230-tool synthetic library -- every test driven by a hand-written `ScriptedChatModel`, no live model anywhere. Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Tool Calling in Spring AI 2.0](https://ankurm.com/spring-ai-2-0-tool-calling/) |
| [`structured-output/`](structured-output) | `ChatClient.entity()` mapping LLM responses to Java records, lists and maps; `StructuredOutputValidationAdvisor` retrying non-conforming JSON with a real enum-constrained schema, including a captured run that exhausts every retry without throwing. Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Structured Output in Spring AI 2.0](https://ankurm.com/spring-ai-2-0-structured-output/) |
+| [`ollama-local/`](ollama-local) | Chat and embeddings against a real local `qwen2.5:0.5b`/`all-minilm`, no API key, driven by a Testcontainers-managed Ollama container started from a baked image; a confirmed model unload via `keep_alive: 0` and `/api/ps`, not a scripted model anywhere. Spring Boot 4.1.1, Spring AI 2.0.1, Testcontainers 2.0.5, Java 25. | [Run LLMs Locally with Spring AI and Ollama](https://ankurm.com/spring-ai-2-0-ollama-local/) |
Upgrading from Spring AI 1.x: [migration guide](https://ankurm.com/spring-ai-1-to-2-migration-guide/).
diff --git a/ollama-local/.gitignore b/ollama-local/.gitignore
new file mode 100644
index 0000000..2f7896d
--- /dev/null
+++ b/ollama-local/.gitignore
@@ -0,0 +1 @@
+target/
diff --git a/ollama-local/README.md b/ollama-local/README.md
new file mode 100644
index 0000000..f455266
--- /dev/null
+++ b/ollama-local/README.md
@@ -0,0 +1,46 @@
+# ollama-local
+
+Companion code for [Run LLMs Locally with Spring AI and Ollama](https://ankurm.com/spring-ai-2-0-ollama-local/), part of the [Spring AI series](../README.md) on ankurm.com.
+
+`spring-ai-starter-model-ollama` autoconfigures a `ChatModel` and an `EmbeddingModel` from `spring.ai.ollama.*` properties alone -- no API key anywhere in this module. Every test in [`LocalChatAndEmbeddingTest.java`](src/test/java/com/ankurm/ollamalocal/LocalChatAndEmbeddingTest.java) drives a real, local Ollama server started by Testcontainers, answering with a real small model (`qwen2.5:0.5b` for chat, `all-minilm` for embeddings) -- unlike every other module in this series, nothing here is scripted.
+
+## Versions
+
+| Component | Version |
+|---|---|
+| Spring Boot | 4.1.1 |
+| Spring AI | 2.0.1 |
+| Testcontainers | 2.0.5 |
+| Java | 25 (LTS) |
+
+`org.testcontainers:ollama` was renamed to **`org.testcontainers:testcontainers-ollama`** in the Testcontainers 2.x line, and `org.testcontainers:junit-jupiter` to **`org.testcontainers:testcontainers-junit-jupiter`** -- both confirmed by reading the real `testcontainers-bom-2.0.5.pom` that Spring Boot 4.1.1 imports. If you're copying an older Testcontainers-Ollama tutorial, the old artifact IDs still exist on Maven Central but are stuck on the 1.x line and are not what this BOM resolves.
+
+## Quickstart
+
+```bash
+./scripts/bake-image.sh # once: pulls qwen2.5:0.5b + all-minilm into a local image
+./scripts/run-all.sh # every time: runs the suite against that baked image
+```
+
+`bake-image.sh` is the real Testcontainers-recommended pattern for CI: pull the models into a container once, `docker commit` the result, and every subsequent test run starts a container that already has them on disk -- no registry pull, no network dependency, no per-run latency for the pull itself.
+
+## What's here
+
+| File | What it shows |
+|---|---|
+| [`OllamaLocalApplication.java`](src/main/java/com/ankurm/ollamalocal/OllamaLocalApplication.java) | The whole application: no manual `OllamaApi`/`OllamaChatModel` wiring, just the starter's autoconfiguration |
+| [`config/ChatClientConfig.java`](src/main/java/com/ankurm/ollamalocal/config/ChatClientConfig.java) | Wraps the autoconfigured `ChatModel` in a `ChatClient`, same as every other module in this series |
+| [`application.yml`](src/main/resources/application.yml) | The five `spring.ai.ollama.*` properties this module uses, with `pull-model-strategy: never` so the app fails fast instead of silently pulling gigabytes at startup |
+| [`LocalChatAndEmbeddingTest.java`](src/test/java/com/ankurm/ollamalocal/LocalChatAndEmbeddingTest.java) | A Testcontainers-managed `OllamaContainer`, a confirmed model unload via `keep_alive: 0` + `/api/ps`, real chat calls, and a deterministic embedding-dimension assertion |
+
+## Output files
+
+| File | Captured from |
+|---|---|
+| `output/01-chat-reload-after-unload.txt` | `reloadAfterAConfirmedUnload` -- unloads the model, confirms via `/api/ps`, then reloads it |
+| `output/02-chat-back-to-back-call.txt` | `backToBackCallReusesTheAlreadyLoadedModel` |
+| `output/03-embedding-dimensions.txt` | `embeddingsAreDeterministicallySized` |
+
+## Requirements
+
+JDK 25, Maven, Docker. Run `scripts/bake-image.sh` before the test suite -- without a baked image present, `scripts/run-all.sh` refuses to start rather than silently pulling ~450MB of models over the network on every test run.
diff --git a/ollama-local/output/01-chat-reload-after-unload.txt b/ollama-local/output/01-chat-reload-after-unload.txt
new file mode 100644
index 0000000..17eadc2
--- /dev/null
+++ b/ollama-local/output/01-chat-reload-after-unload.txt
@@ -0,0 +1,10 @@
+/api/ps immediately after the unload call: {"models":[]}
+
+prompt: "Reply with a single short sentence: why do developers like small local models?"
+response: Developers often prefer small local models because they are more efficient, faster, and easier to deploy and train.
+
+total-duration: 901ms
+load-duration: 1ms
+prompt-eval-count: 44, prompt-eval-duration: 39ms
+eval-count: 23, eval-duration: 856ms
+26.87 tokens/sec (eval-count / eval-duration)
\ No newline at end of file
diff --git a/ollama-local/output/02-chat-back-to-back-call.txt b/ollama-local/output/02-chat-back-to-back-call.txt
new file mode 100644
index 0000000..8970e73
--- /dev/null
+++ b/ollama-local/output/02-chat-back-to-back-call.txt
@@ -0,0 +1,8 @@
+prompt: "Reply with a single short sentence: what is Testcontainers for?"
+response: Testcontainers is a popular containerization platform that makes it easy to create and manage application containers for testing, development, and production environments.
+
+total-duration: 722ms
+load-duration: 2ms
+prompt-eval-count: 42, prompt-eval-duration: 61ms
+eval-count: 17, eval-duration: 653ms
+26.02 tokens/sec (eval-count / eval-duration)
\ No newline at end of file
diff --git a/ollama-local/output/03-embedding-dimensions.txt b/ollama-local/output/03-embedding-dimensions.txt
new file mode 100644
index 0000000..bb809e4
--- /dev/null
+++ b/ollama-local/output/03-embedding-dimensions.txt
@@ -0,0 +1,4 @@
+model: all-minilm
+dimensions(): 384
+vector.length: 384
+first 8 values: [-0.02818, -0.00275, -0.02190, -0.03044, 0.02368, -0.08213, -0.10182, -0.05197, ...]
\ No newline at end of file
diff --git a/ollama-local/pom.xml b/ollama-local/pom.xml
new file mode 100644
index 0000000..eed9df6
--- /dev/null
+++ b/ollama-local/pom.xml
@@ -0,0 +1,85 @@
+
+
Every other module in this series drives {@code ChatClient} against a hand-written
+ * {@code ScriptedChatModel} so the tests are deterministic and need no live model. This module
+ * is the deliberate exception: the whole point of "run it locally" is a real model answering a
+ * real prompt, so these tests assert only what is genuinely deterministic about a live small
+ * model (a non-blank response, a confirmed-empty model registry right after an explicit unload)
+ * and leave exact wording, and exact timings, alone.
+ */
+@Testcontainers
+@SpringBootTest
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+class LocalChatAndEmbeddingTest {
+
+ @Container
+ static final OllamaContainer OLLAMA = new OllamaContainer(
+ DockerImageName.parse("ollama-baked-qwen05b-minilm:local").asCompatibleSubstituteFor("ollama/ollama"));
+
+ @DynamicPropertySource
+ static void ollamaProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.ai.ollama.base-url", OLLAMA::getEndpoint);
+ }
+
+ private static final HttpClient HTTP = HttpClient.newHttpClient();
+
+ @Autowired
+ private ChatClient chatClient;
+
+ @Autowired
+ private ChatModel chatModel;
+
+ @Autowired
+ private EmbeddingModel embeddingModel;
+
+ @Test
+ @Order(1)
+ void reloadAfterAConfirmedUnload() throws Exception {
+ // keepAlive("0") tells Ollama to unload the model from memory as soon as this call
+ // finishes -- the same knob you'd reach for in production to free RAM/VRAM between
+ // bursts of traffic. It's set through ChatModel.call() with an explicit Prompt rather
+ // than through ChatClient.prompt().options(...): with this module's versions, an option
+ // set that way never reaches the request Ollama receives, which the going-deeper note
+ // below covers.
+ this.chatModel
+ .call(new Prompt("OK", OllamaChatOptions.builder().model("qwen2.5:0.5b").keepAlive("0").build()));
+
+ // Don't take the unload on faith -- ask Ollama's own /api/ps, which lists every
+ // currently loaded model, and confirm the registry is really empty before measuring
+ // what a reload costs.
+ String modelsAfterUnload = getModelRegistry();
+ assertThat(modelsAfterUnload).contains("\"models\":[]");
+
+ ChatClient.CallResponseSpec response = this.chatClient.prompt()
+ .user("Reply with a single short sentence: why do developers like small local models?")
+ .call();
+
+ String content = response.content();
+ ChatResponseMetadata metadata = response.chatResponse().getMetadata();
+
+ assertThat(content).isNotBlank();
+ // Loading is real work -- reading weights off disk and initializing the runtime -- so
+ // even a fast reload can't be negative or missing.
+ Duration loadDuration = metadata.get("load-duration");
+ assertThat(loadDuration).isNotNull();
+ assertThat(loadDuration.isNegative()).isFalse();
+
+ Transcript.write("01-chat-reload-after-unload",
+ "/api/ps immediately after the unload call: " + modelsAfterUnload + "\n\n"
+ + "prompt: \"Reply with a single short sentence: why do developers like small local models?\"\n"
+ + "response: " + content + "\n\n" + formatOllamaMetadata(metadata));
+ }
+
+ @Test
+ @Order(2)
+ void backToBackCallReusesTheAlreadyLoadedModel() {
+ ChatClient.CallResponseSpec response = this.chatClient.prompt()
+ .user("Reply with a single short sentence: what is Testcontainers for?")
+ .call();
+
+ String content = response.content();
+ ChatResponseMetadata metadata = response.chatResponse().getMetadata();
+
+ assertThat(content).isNotBlank();
+
+ Transcript.write("02-chat-back-to-back-call",
+ "prompt: \"Reply with a single short sentence: what is Testcontainers for?\"\n"
+ + "response: " + content + "\n\n" + formatOllamaMetadata(metadata));
+ }
+
+ @Test
+ @Order(3)
+ void embeddingsAreDeterministicallySized() {
+ float[] vector = this.embeddingModel.embed("Spring AI can run entirely against a local Ollama server.");
+
+ assertThat(vector).hasSize(384);
+ assertThat(this.embeddingModel.dimensions()).isEqualTo(384);
+
+ StringBuilder prefix = new StringBuilder();
+ for (int i = 0; i < 8; i++) {
+ if (i > 0) {
+ prefix.append(", ");
+ }
+ prefix.append(String.format("%.5f", vector[i]));
+ }
+
+ Transcript.write("03-embedding-dimensions",
+ "model: all-minilm\n" + "dimensions(): " + this.embeddingModel.dimensions() + "\n"
+ + "vector.length: " + vector.length + "\n" + "first 8 values: [" + prefix + ", ...]");
+ }
+
+ private static String getModelRegistry() throws Exception {
+ HttpRequest request = HttpRequest.newBuilder(URI.create(OLLAMA.getEndpoint() + "/api/ps")).GET().build();
+ return HTTP.send(request, HttpResponse.BodyHandlers.ofString()).body();
+ }
+
+ private static String formatOllamaMetadata(ChatResponseMetadata metadata) {
+ Duration total = metadata.get("total-duration");
+ Duration load = metadata.get("load-duration");
+ Duration promptEval = metadata.get("prompt-eval-duration");
+ Duration eval = metadata.get("eval-duration");
+ Integer promptEvalCount = metadata.get("prompt-eval-count");
+ Integer evalCount = metadata.get("eval-count");
+
+ StringBuilder sb = new StringBuilder();
+ sb.append("total-duration: ").append(total.toMillis()).append("ms\n");
+ sb.append("load-duration: ").append(load.toMillis()).append("ms\n");
+ sb.append("prompt-eval-count: ").append(promptEvalCount);
+ sb.append(", prompt-eval-duration: ").append(promptEval.toMillis()).append("ms\n");
+ sb.append("eval-count: ").append(evalCount);
+ sb.append(", eval-duration: ").append(eval.toMillis()).append("ms");
+
+ if (evalCount != null && eval != null && eval.toNanos() > 0) {
+ double tokensPerSecond = evalCount / (eval.toNanos() / 1_000_000_000.0);
+ sb.append(String.format("%n%.2f tokens/sec (eval-count / eval-duration)", tokensPerSecond));
+ }
+ return sb.toString();
+ }
+
+}
diff --git a/ollama-local/src/test/java/com/ankurm/ollamalocal/support/Transcript.java b/ollama-local/src/test/java/com/ankurm/ollamalocal/support/Transcript.java
new file mode 100644
index 0000000..8b42d62
--- /dev/null
+++ b/ollama-local/src/test/java/com/ankurm/ollamalocal/support/Transcript.java
@@ -0,0 +1,36 @@
+package com.ankurm.ollamalocal.support;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/**
+ * Writes a test's captured, real output to {@code output/