Skip to content

S3 configuration

This guide covers how to configure S3-compatible object storage for integration testing with tomato.

Overview

The s3 resource talks to any S3-compatible endpoint. In tests that usually means MinIO or LocalStack, but the same resource works against a real bucket if you point it at one.

Because it speaks the S3 protocol rather than a vendor API, one set of steps covers all three.

Container setup

MinIO

MinIO needs an explicit server command — the image's default entrypoint only prints help.

containers:
  minio:
    image: cgr.dev/chainguard/minio:latest
    command: ["server", "/data"]
    ports:
      - "9000/tcp"
    env:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin
    wait_for:
      type: port
      target: "9000"
      timeout: 30s

LocalStack

containers:
  localstack:
    image: localstack/localstack:3
    ports:
      - "4566/tcp"
    env:
      SERVICES: s3
    wait_for:
      type: port
      target: "4566"
      timeout: 90s

Resource configuration

resources:
  files:
    type: s3
    container: minio
    options:
      buckets:
        - uploads
        - reports

minio is accepted as an alias for type: s3.

The endpoint is derived from the container automatically. Tomato probes port 9000 (MinIO) and then 4566 (LocalStack), so neither image needs a port option.

Options

Option Default Description
endpoint (from container) Explicit endpoint, e.g. localhost:9000. Scheme is optional.
port 9000 then 4566 Container port to resolve the endpoint from.
region us-east-1 AWS region.
access_key minioadmin Access key ID.
secret_key minioadmin Secret access key.
use_ssl false Use https:// when building the endpoint.
force_path_style true Path-style addressing. Required by MinIO and LocalStack.
buckets (none) Buckets created at startup, before any scenario runs.
reset_strategy purge purge or delete. See below.
reset_buckets (all buckets) Limit reset to these buckets.
reset_exclude (none) Buckets reset never touches.

Connecting without a container

Point the resource at an endpoint directly to test against something tomato does not manage:

resources:
  files:
    type: s3
    options:
      endpoint: "https://s3.eu-central-1.amazonaws.com"
      region: eu-central-1
      use_ssl: true
      force_path_style: false
      access_key: "${AWS_ACCESS_KEY_ID}"
      secret_key: "${AWS_SECRET_ACCESS_KEY}"

Reset deletes objects

Reset is destructive by design. Against a real account, set scope it with reset_buckets.

Reset behaviour

Following tomato's clean-state principle, every scenario starts with empty storage.

Strategy Behaviour
purge (default) Deletes every object, keeps the buckets.
delete Deletes the buckets entirely, then recreates those listed in buckets.
none Leaves storage untouched.

By default reset covers every bucket on the endpoint, not just the ones in buckets. That way buckets your application creates at runtime are cleaned up too. Narrow it with reset_buckets, or protect individual buckets with reset_exclude:

resources:
  files:
    type: s3
    container: minio
    options:
      buckets: [uploads]
      reset_exclude: [fixtures]   # seeded once in a before_all hook

A resource cannot opt out of reset altogether: every resource is wiped before every scenario. reset_exclude is how a bucket survives, and a before_all hook is where its contents are seeded.

Wiring your application

Pass the endpoint to the application under test with container templates:

app:
  command: go run ./cmd/server
  port: 8080
  env:
    S3_ENDPOINT: "http://{{.minio.host}}:{{.minio.port.9000}}"
    S3_ACCESS_KEY: minioadmin
    S3_SECRET_KEY: minioadmin
    S3_FORCE_PATH_STYLE: "true"

Object paths

Every object step takes a single bucket/key path. An s3:// prefix is stripped if present.

Given "files" object "uploads/2026/01/report.pdf" is "..."

Writing to a bucket that does not exist creates it, so seeding steps do not need a bucket ... exists line first.

Example

Feature: Report export

  Scenario: Exporting writes a report to S3
    Given "db" table "orders" has values:
      | id | total |
      | 1  | 42.00 |
    When "api" sends "POST" to "/exports"
    Then "api" response status is "202"
    And "files" object "reports/orders.csv" should exist within "10s"
    And "files" object "reports/orders.csv" content should contain "42.00"

The within steps matter here: applications usually upload in the background, so a plain should exist would race the write.

See also