|
| 1 | +--- |
| 2 | +title: Connect Amazon S3 to Meilisearch with Kestra |
| 3 | +sidebarTitle: Amazon S3 |
| 4 | +description: Backfill and event-driven sync of Amazon S3 objects into Meilisearch with Kestra. |
| 5 | +--- |
| 6 | + |
| 7 | +A huge amount of the world's data arrives as files in object storage: nightly exports, partner data drops, analytics dumps, catalog CSVs. Amazon S3 (and every S3-compatible store, such as MinIO, Cloudflare R2, or Google Cloud Storage in interop mode) is where they land. Meilisearch is where you want them searchable. This guide connects the two with [Kestra](https://kestra.io). |
| 8 | + |
| 9 | +This guide covers both halves of the real problem: a one-shot load of an existing object, and then an **event-driven** pipeline where dropping a new file into a bucket makes its contents searchable within seconds. No manual step or polling script required. |
| 10 | + |
| 11 | +## Why orchestrate the sync |
| 12 | + |
| 13 | +Files arrive unpredictably and formats vary. You want a pipeline that reacts to new files automatically, converts whatever format they're in, indexes them reliably, and, crucially, processes each file exactly once. Kestra gives you a bucket trigger, format converters, and the Meilisearch task, wired together declaratively with full logging and retries. |
| 14 | + |
| 15 | +## Prerequisites |
| 16 | + |
| 17 | +A running Kestra with three plugins (Meilisearch, AWS, and the serdes plugin for CSV/JSON conversion), plus a [Meilisearch Cloud](https://www.meilisearch.com/cloud?utm_campaign=oss&utm_source=docs&utm_medium=kestra-integration) project. Only Kestra runs locally, since Meilisearch is managed: |
| 18 | + |
| 19 | +```yaml |
| 20 | +services: |
| 21 | + kestra: |
| 22 | + image: kestra/kestra:latest |
| 23 | + command: server local |
| 24 | + ports: ["8080:8080"] |
| 25 | + environment: |
| 26 | + # your Meilisearch Cloud Default Admin API key, base64-encoded |
| 27 | + SECRET_MEILISEARCH_API_KEY: <base64 of your admin API key> |
| 28 | +``` |
| 29 | +
|
| 30 | +```dockerfile |
| 31 | +FROM kestra/kestra:latest |
| 32 | +RUN /app/kestra plugins install \ |
| 33 | + io.kestra.plugin:plugin-meilisearch:LATEST \ |
| 34 | + io.kestra.plugin:plugin-aws:LATEST \ |
| 35 | + io.kestra.plugin:plugin-serdes:LATEST |
| 36 | +``` |
| 37 | + |
| 38 | +<Note> |
| 39 | +**Get your Cloud credentials.** In the [Meilisearch Cloud](https://cloud.meilisearch.com) dashboard, create a project and copy its **Project URL** (the `url` in the flows below) and its **Default Admin API Key** (Settings, then API Keys). Store both AWS and Meilisearch credentials as Kestra secrets. This guide shows an S3-compatible endpoint (MinIO) with inline keys for clarity. For real AWS S3, drop `endpointOverride` / `compatibilityMode` / `forcePathStyle` and supply `accessKeyId` / `secretKeyId` (or an IAM role) via secrets. |
| 40 | +</Note> |
| 41 | + |
| 42 | +The examples index a `games.csv` file with columns `id,title,platform,genre,rating`. |
| 43 | + |
| 44 | +## Step 1: The first load (backfill) |
| 45 | + |
| 46 | +Three steps: download the object, convert CSV to Kestra's ION format, and index it. The serdes plugin bridges the format gap: `DocumentAdd` speaks ION, and `CsvToIon` produces exactly that. |
| 47 | + |
| 48 | +```yaml |
| 49 | +id: s3_csv_to_meilisearch |
| 50 | +namespace: company.search |
| 51 | + |
| 52 | +variables: |
| 53 | + meilisearch_url: https://ms-xxxxxxxxxxxx-xxxx.meilisearch.io # your Meilisearch Cloud Project URL |
| 54 | + index: games |
| 55 | + |
| 56 | +tasks: |
| 57 | + - id: download |
| 58 | + type: io.kestra.plugin.aws.s3.Download |
| 59 | + accessKeyId: minioadmin |
| 60 | + secretKeyId: minioadmin |
| 61 | + region: us-east-1 |
| 62 | + endpointOverride: http://minio:9000 # omit for real AWS S3 |
| 63 | + compatibilityMode: true # omit for real AWS S3 |
| 64 | + forcePathStyle: true # omit for real AWS S3 |
| 65 | + bucket: datasets |
| 66 | + key: games.csv |
| 67 | + |
| 68 | + - id: to_ion |
| 69 | + type: io.kestra.plugin.serdes.csv.CsvToIon |
| 70 | + from: "{{ outputs.download.uri }}" |
| 71 | + |
| 72 | + - id: index_documents |
| 73 | + type: io.kestra.plugin.meilisearch.DocumentAdd |
| 74 | + from: "{{ outputs.to_ion.uri }}" |
| 75 | + index: "{{ vars.index }}" |
| 76 | + url: "{{ vars.meilisearch_url }}" |
| 77 | + key: "{{ secret('MEILISEARCH_API_KEY') }}" |
| 78 | +``` |
| 79 | +
|
| 80 | +<Warning> |
| 81 | +**S3-compatible storage gotcha.** For MinIO, R2, and friends you need both `compatibilityMode: true` and `forcePathStyle: true`. Without them the AWS SDK uses virtual-host addressing (`bucket.your-endpoint`) and fails on DNS resolution. On real AWS S3, leave all three lines out. |
| 82 | +</Warning> |
| 83 | + |
| 84 | +Swap `CsvToIon` for `JsonToIon` or `AvroToIon` if your files arrive in those formats. The rest of the pipeline is identical. |
| 85 | + |
| 86 | +One thing to know about CSV: `CsvToIon` emits every column as a **string** (`"rating":"96"`). If you want to filter or sort numerically in Meilisearch, either cast the values in a transform step, or configure the attribute accordingly and rely on Meilisearch's numeric handling. |
| 87 | + |
| 88 | +## Step 2: Event-driven sync (files as they arrive) |
| 89 | + |
| 90 | +The backfill indexes a file you name explicitly. The real workflow is: a new file lands in the bucket and gets indexed on its own. Kestra's S3 `Trigger` polls a prefix and starts an execution whenever new objects appear, and it can move or delete each object after it's handed off, giving you **exactly-once** processing. |
| 91 | + |
| 92 | +Put incoming files under an `incoming/` prefix and let the trigger drain it: |
| 93 | + |
| 94 | +```yaml |
| 95 | +id: s3_event_to_meilisearch |
| 96 | +namespace: company.search |
| 97 | +
|
| 98 | +variables: |
| 99 | + meilisearch_url: https://ms-xxxxxxxxxxxx-xxxx.meilisearch.io # your Meilisearch Cloud Project URL |
| 100 | + index: games |
| 101 | +
|
| 102 | +triggers: |
| 103 | + - id: on_new_file |
| 104 | + type: io.kestra.plugin.aws.s3.Trigger |
| 105 | + interval: PT10S # poll the prefix every 10 seconds |
| 106 | + accessKeyId: minioadmin |
| 107 | + secretKeyId: minioadmin |
| 108 | + region: us-east-1 |
| 109 | + endpointOverride: http://minio:9000 |
| 110 | + compatibilityMode: true |
| 111 | + forcePathStyle: true |
| 112 | + bucket: datasets |
| 113 | + prefix: incoming/ |
| 114 | + action: DELETE # remove each object once handed to the flow |
| 115 | +
|
| 116 | +tasks: |
| 117 | + - id: to_ion |
| 118 | + type: io.kestra.plugin.serdes.csv.CsvToIon |
| 119 | + from: "{{ trigger.objects[0].uri }}" |
| 120 | +
|
| 121 | + - id: index_documents |
| 122 | + type: io.kestra.plugin.meilisearch.DocumentAdd |
| 123 | + from: "{{ outputs.to_ion.uri }}" |
| 124 | + index: "{{ vars.index }}" |
| 125 | + url: "{{ vars.meilisearch_url }}" |
| 126 | + key: "{{ secret('MEILISEARCH_API_KEY') }}" |
| 127 | +``` |
| 128 | + |
| 129 | +How it behaves: the trigger checks `incoming/` every ten seconds. When a file appears, it downloads it into Kestra's internal storage (available as `{{ trigger.objects[0].uri }}`), fires the flow, and then deletes the object from the bucket per `action: DELETE`. The flow converts and indexes it. Drop a CSV, and its rows are searchable seconds later, hands-off. |
| 130 | + |
| 131 | +Prefer to keep an audit trail of processed files? Use `action: MOVE` with a `moveTo` destination to archive each object into a `processed/` prefix instead of deleting it. |
| 132 | + |
| 133 | +## Handling updates and deletes |
| 134 | + |
| 135 | +Object drops are naturally an **upsert** stream: because `DocumentAdd` is add-or-replace, a file re-exported with corrected rows overwrites the matching documents by primary key when it's dropped again. No special handling needed for updates. |
| 136 | + |
| 137 | +Deletes are the one case files don't express well: a file that simply stops appearing can't tell Meilisearch to remove anything. Two options: |
| 138 | + |
| 139 | +- Include a `deleted` marker column in your exports and add a branch that calls Meilisearch's `documents/delete-batch` endpoint for those ids (the pattern is shown in [Connect PostgreSQL to Meilisearch with Kestra](/getting_started/integrations/kestra/postgresql)). |
| 140 | +- For full-snapshot files, periodically re-index into a fresh index and swap it in with an index alias, so removed rows disappear. |
| 141 | + |
| 142 | +## Going to production |
| 143 | + |
| 144 | +- **Real AWS S3:** remove `endpointOverride`, `compatibilityMode`, and `forcePathStyle`. Authenticate with an IAM role or with `accessKeyId` / `secretKeyId` pulled from Kestra secrets. |
| 145 | +- **Large files:** `CsvToIon` and `DocumentAdd` stream through internal storage and batch automatically, so multi-gigabyte files work without tuning. Raise `DocumentAdd`'s `batchSize` if you want fewer, larger indexing tasks. |
| 146 | +- **Multiple files at once:** the trigger surfaces every matched object in `{{ trigger.objects }}`. Loop over them with an `EachSequential`/`ForEach` task if a poll can pick up more than one file. |
| 147 | +- **Retries:** add a `retry` block so a transient S3 or Meilisearch hiccup self-heals rather than failing the execution. |
| 148 | + |
| 149 | +## Wrap-up |
| 150 | + |
| 151 | +Two flows turn object storage into a live search source: a backfill for files already in the bucket, and an event-driven pipeline where new drops are converted and indexed automatically, each processed exactly once. It works identically on Amazon S3 and any S3-compatible store. Point the trigger at your bucket and let Kestra do the rest. |
0 commit comments