Repository: benbjohnson/litestream

Issue:
Fix Tigris S3-compatible storage compatibility and add CI integration tests
## Problem

Recent changes to improve S3-compatible storage compatibility with OCI and Filebase may have broken Tigris support. Two specific commits are suspected:

1. **Primary suspect:** PR #812 (commit e40c8a5) - Changed S3 uploads to use `UNSIGNED-PAYLOAD`
   - Changed from signed payloads (with SHA256 hash) to unsigned payloads
   - Fixed Filebase compatibility but may break Tigris if it requires signed payloads
   - Affects all PUT operations (snapshots, LTX files, WAL segments)

2. **Secondary suspect:** PR #802 (commit 012b75c) - Added `Content-MD5` headers to DeleteObjects
   - Added Content-MD5 headers for OCI compatibility
   - May cause issues if Tigris validates Content-MD5 differently or rejects it

## Background

Tigris is a globally distributed S3-compatible object storage service. Litestream already has test coverage for Tigris URL parsing (`cmd/litestream/main_test.go:1583, 2212`) and documents configuration as:
```
s3://bucket/db?endpoint=fly.storage.tigris.dev&region=auto
```

However, there are no integration tests that actually exercise Tigris storage operations in CI.

## Plan

### Phase 1: Investigation & Root Cause Analysis
1. Set up Tigris test environment with free tier account
2. Test PUT operations (likely fails due to UNSIGNED-PAYLOAD)
3. Test DELETE operations (may fail due to Content-MD5 validation)
4. Capture exact error messages and confirm root cause
5. Research Tigris S3 API requirements for signed vs unsigned payloads

### Phase 2: Fix Implementation
6. Add configurable payload signing to `s3/replica_client.go`
   - Add `SignPayload bool` config field (default: false for backward compat)
   - Conditionally apply unsigned payload middleware based on config
   - Keeps Filebase/OCI compatibility while allowing Tigris users to enable signing

7. Make Content-MD5 configurable (if needed)
   - Add `RequireContentMD5 bool` config option
   - Default to true (maintains OCI compatibility)
   - Allow override if Tigris rejects it

8. Update configuration parsing
   - Parse new options from YAML config and URL query parameters
   - Example: `s3://bucket/db?endpoint=fly.storage.tigris.dev&region=auto&sign-payload=true`

### Phase 3: Documentation & Examples
9. Document Tigris configuration with examples
10. Update `etc/litestream.yml` with commented Tigris example
11. Document compatibility matrix for different S3-compatible providers

### Phase 4: Integration Testing (CI)
12. Create Tigris integration test in `replica_client_test.go` with `-integration tigris` flag
13. Test full replication cycle: replicate → restore → verify
14. Test compaction (DeleteObjects operations)
15. Test point-in-time restore with metadata timestamps

16. Add GitHub Actions CI workflow for Tigris
    - Use GitHub secrets for Tigris credentials (free tier)
    - Run on PRs touching S3-related code
    - Run nightly to catch Tigris API changes

### Phase 5: Regression Prevention
17. Ensure test coverage for multiple providers (S3, Tigris, Filebase, OCI)
18. Document which providers require which settings
19. Document how to run Tigris tests locally

## Deliverables
- ✅ Tigris compatibility restored with configurable payload signing
- ✅ Backward compatibility maintained (OCI, Filebase, S3 still work)
- ✅ CI integration tests prevent future regressions
- ✅ Documentation for Tigris users
- ✅ Multi-provider test coverage

## References
- Tigris S3 API docs: https://www.tigrisdata.com/docs/api/s3/
- Existing Tigris tests: `cmd/litestream/main_test.go:1583, 2212`
- PR #812: Unsigned payload change
- PR #802: Content-MD5 header addition

Hints from issue comments:
@github-actions: ❌ **Manual integration tests** have been run by @corylanou

Failed jobs: S3, GCS, Azure, Tigris

[View test results](https://github.com/benbjohnson/litestream/actions/runs/19477429390)

Solution patch:
diff --git a/.github/workflows/manual-integration-tests.yml b/.github/workflows/manual-integration-tests.yml
index 65218d2..5876fd3 100644
--- a/.github/workflows/manual-integration-tests.yml
+++ b/.github/workflows/manual-integration-tests.yml
@@ -31,6 +31,11 @@ on:
         required: false
         default: false
         type: boolean
+      test_tigris:
+        description: 'Run Fly.io Tigris integration tests'
+        required: false
+        default: false
+        type: boolean
 
 jobs:
   setup:
@@ -61,7 +66,8 @@ jobs:
         run: |
           if [ "${{ github.event.inputs.test_s3 }}" != "true" ] && \
              [ "${{ github.event.inputs.test_gcs }}" != "true" ] && \
-             [ "${{ github.event.inputs.test_abs }}" != "true" ]; then
+             [ "${{ github.event.inputs.test_abs }}" != "true" ] && \
+             [ "${{ github.event.inputs.test_tigris }}" != "true" ]; then
             echo "::error::At least one test type must be selected"
             exit 1
           fi
@@ -74,6 +80,7 @@ jobs:
           echo "- S3 (AWS): ${{ github.event.inputs.test_s3 }}" >> $GITHUB_STEP_SUMMARY
           echo "- Google Cloud Storage: ${{ github.event.inputs.test_gcs }}" >> $GITHUB_STEP_SUMMARY
           echo "- Azure Blob Storage: ${{ github.event.inputs.test_abs }}" >> $GITHUB_STEP_SUMMARY
+          echo "- Fly.io Tigris: ${{ github.event.inputs.test_tigris }}" >> $GITHUB_STEP_SUMMARY
 
   s3-integration:
     name: S3 Integration Tests (AWS)
@@ -96,7 +103,7 @@ jobs:
 
       - run: go install ./cmd/litestream
 
-      - run: go test -v ./replica_client_test.go -integration s3
+      - run: go test -v ./replica_client_test.go -integration -replica-clients=s3
         env:
           LITESTREAM_S3_ACCESS_KEY_ID: ${{ secrets.LITESTREAM_S3_ACCESS_KEY_ID }}
           LITESTREAM_S3_SECRET_ACCESS_KEY: ${{ secrets.LITESTREAM_S3_SECRET_ACCESS_KEY }}
@@ -118,6 +125,47 @@ jobs:
             *.log
             test-results/
 
+  tigris-integration:
+    name: Tigris Integration Tests
+    runs-on: ubuntu-latest
+    needs: setup
+    if: github.event.inputs.test_tigris == 'true'
+    concurrency:
+      group: integration-test-tigris-manual
+      cancel-in-progress: false
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          ref: ${{ needs.setup.outputs.ref }}
+
+      - uses: actions/setup-go@v5
+        with:
+          go-version-file: "go.mod"
+
+      - run: go env
+
+      - run: go install ./cmd/litestream
+
+      - run: go test -v ./replica_client_test.go -integration -replica-clients=tigris
+        env:
+          LITESTREAM_TIGRIS_ACCESS_KEY_ID: ${{ secrets.LITESTREAM_TIGRIS_ACCESS_KEY_ID }}
+          LITESTREAM_TIGRIS_SECRET_ACCESS_KEY: ${{ secrets.LITESTREAM_TIGRIS_SECRET_ACCESS_KEY }}
+
+      - name: Create test results directory
+        if: always()
+        run: |
+          mkdir -p test-results
+          echo "Test completed at $(date)" > test-results/tigris-test.log
+
+      - name: Upload test results
+        if: always()
+        uses: actions/upload-artifact@v4
+        with:
+          name: tigris-test-results
+          path: |
+            *.log
+            test-results/
+
   gcs-integration:
     name: Google Cloud Storage Integration Tests
     runs-on: ubuntu-latest
@@ -145,7 +193,7 @@ jobs:
 
       - run: go install ./cmd/litestream
 
-      - run: go test -v ./replica_client_test.go -integration gs
+      - run: go test -v ./replica_client_test.go -integration -replica-clients=gs
         env:
           GOOGLE_APPLICATION_CREDENTIALS: /opt/gcp.json
           LITESTREAM_GS_BUCKET: litestream-github-workflows
@@ -186,7 +234,7 @@ jobs:
 
       - run: go install ./cmd/litestream
 
-      - run: go test -v ./replica_client_test.go -integration abs
+      - run: go test -v ./replica_client_test.go -integration -replica-clients=abs
         env:
           LITESTREAM_ABS_ACCOUNT_NAME: ${{ secrets.LITESTREAM_ABS_ACCOUNT_NAME }}
           LITESTREAM_ABS_ACCOUNT_KEY: ${{ secrets.LITESTREAM_ABS_ACCOUNT_KEY }}
@@ -210,7 +258,7 @@ jobs:
   summary:
     name: Test Summary
     runs-on: ubuntu-latest
-    needs: [setup, s3-integration, gcs-integration, abs-integration]
+    needs: [setup, s3-integration, gcs-integration, abs-integration, tigris-integration]
     if: always()
     steps:
       - name: Download all artifacts
@@ -250,6 +298,14 @@ jobs:
             echo "⏭️ **Azure Blob Storage:** Skipped" >> $GITHUB_STEP_SUMMARY
           fi
 
+          if [ "${{ needs.tigris-integration.result }}" == "success" ]; then
+            echo "✅ **Fly.io Tigris:** Passed" >> $GITHUB_STEP_SUMMARY
+          elif [ "${{ needs.tigris-integration.result }}" == "failure" ]; then
+            echo "❌ **Fly.io Tigris:** Failed" >> $GITHUB_STEP_SUMMARY
+          elif [ "${{ needs.tigris-integration.result }}" == "skipped" ]; then
+            echo "⏭️ **Fly.io Tigris:** Skipped" >> $GITHUB_STEP_SUMMARY
+          fi
+
           echo "" >> $GITHUB_STEP_SUMMARY
           echo "---" >> $GITHUB_STEP_SUMMARY
           echo "**Triggered by:** @${{ github.actor }}" >> $GITHUB_STEP_SUMMARY
@@ -272,6 +328,7 @@ jobs:
             if ('${{ needs.s3-integration.result }}' === 'failure') failedJobs.push('S3');
             if ('${{ needs.gcs-integration.result }}' === 'failure') failedJobs.push('GCS');
             if ('${{ needs.abs-integration.result }}' === 'failure') failedJobs.push('Azure');
+            if ('${{ needs.tigris-integration.result }}' === 'failure') failedJobs.push('Tigris');
 
             if (failedJobs.length > 0) {
               statusEmoji = '❌';
diff --git a/abs/replica_client.go b/abs/replica_client.go
index c7d5af3..966584e 100644
--- a/abs/replica_client.go
+++ b/abs/replica_client.go
@@ -275,7 +275,8 @@ func (c *ReplicaClient) DeleteAll(ctx context.Context) error {
 	}
 
 	pager := c.client.NewListBlobsFlatPager(c.Bucket, &azblob.ListBlobsFlatOptions{
-		Prefix: &prefix,
+		Prefix:  &prefix,
+		Include: azblob.ListBlobsInclude{Metadata: true},
 	})
 
 	for pager.More() {
@@ -336,7 +337,8 @@ func newLTXFileIterator(ctx context.Context, client *ReplicaClient, level int, s
 	}
 
 	itr.pager = client.client.NewListBlobsFlatPager(client.Bucket, &azblob.ListBlobsFlatOptions{
-		Prefix: &prefix,
+		Prefix:  &prefix,
+		Include: azblob.ListBlobsInclude{Metadata: true},
 	})
 
 	return itr
diff --git a/etc/litestream.yml b/etc/litestream.yml
index 3279853..2c6a42a 100644
--- a/etc/litestream.yml
+++ b/etc/litestream.yml
@@ -7,6 +7,10 @@
 #    replica:
 #      path: /path/to/replica             # File-based replication
 #      url:  s3://my.bucket.com/db        # S3-based replication
+#      # Example Fly.io Tigris setup (requires signed payloads & no Content-MD5 deletes)
+#      # url: s3://my-tigris-bucket/db.sqlite?endpoint=fly.storage.tigris.dev&region=auto
+#      # sign-payload: true
+#      # require-content-md5: false
 #      type: nats                         # NATS JetStream replication
 #      url: nats://nats.example.com:4222
 #      bucket: litestream-backups
diff --git a/s3/replica_client.go b/s3/replica_client.go
index c92c80d..2eeb988 100644
--- a/s3/replica_client.go
+++ b/s3/replica_client.go
@@ -68,12 +68,14 @@ type ReplicaClient struct {
 	SecretAccessKey string
 
 	// S3 bucket information
-	Region         string
-	Bucket         string
-	Path           string
-	Endpoint       string
-	ForcePathStyle bool
-	SkipVerify     bool
+	Region            string
+	Bucket            string
+	Path              string
+	Endpoint          string
+	ForcePathStyle    bool
+	SkipVerify        bool
+	SignPayload       bool
+	RequireContentMD5 bool
 
 	// Upload configuration
 	PartSize    int64 // Part size for multipart uploads (default: 5MB)
@@ -83,7 +85,8 @@ type ReplicaClient struct {
 // NewReplicaClient returns a new instance of ReplicaClient.
 func NewReplicaClient() *ReplicaClient {
 	return &ReplicaClient{
-		logger: slog.Default().WithGroup(ReplicaClientType),
+		logger:            slog.Default().WithGroup(ReplicaClientType),
+		RequireContentMD5: true,
 	}
 }
 
@@ -179,8 +182,8 @@ func (c *ReplicaClient) Init(ctx context.Context) (err error) {
 	s3Opts := []func(*s3.Options){
 		func(o *s3.Options) {
 			o.UsePathStyle = c.ForcePathStyle
-			// Add User-Agent and Content-MD5 middleware.
-			o.APIOptions = append(o.APIOptions, litestreamAPIOption())
+			// Add User-Agent and optional middleware.
+			o.APIOptions = append(o.APIOptions, c.middlewareOption())
 		},
 	}
 
@@ -372,37 +375,39 @@ func (c *ReplicaClient) WriteLTXFile(ctx context.Context, level int, minTXID, ma
 	return info, nil
 }
 
-func litestreamAPIOption() func(*middleware.Stack) error {
+func (c *ReplicaClient) middlewareOption() func(*middleware.Stack) error {
 	return func(stack *middleware.Stack) error {
-		if err := stack.Serialize.Add(
-			middleware.SerializeMiddlewareFunc(
-				"LitestreamComputeDeleteContentMD5",
-				func(ctx context.Context, in middleware.SerializeInput, next middleware.SerializeHandler) (
-					out middleware.SerializeOutput, metadata middleware.Metadata, err error,
-				) {
-					if middleware.GetOperationName(ctx) != "DeleteObjects" {
-						return next.HandleSerialize(ctx, in)
-					}
+		if c.RequireContentMD5 {
+			if err := stack.Serialize.Add(
+				middleware.SerializeMiddlewareFunc(
+					"LitestreamComputeDeleteContentMD5",
+					func(ctx context.Context, in middleware.SerializeInput, next middleware.SerializeHandler) (
+						out middleware.SerializeOutput, metadata middleware.Metadata, err error,
+					) {
+						if middleware.GetOperationName(ctx) != "DeleteObjects" {
+							return next.HandleSerialize(ctx, in)
+						}
 
-					input, ok := in.Parameters.(*s3.DeleteObjectsInput)
-					if !ok || input == nil || input.Delete == nil || len(input.Delete.Objects) == 0 {
-						return next.HandleSerialize(ctx, in)
-					}
+						input, ok := in.Parameters.(*s3.DeleteObjectsInput)
+						if !ok || input == nil || input.Delete == nil || len(input.Delete.Objects) == 0 {
+							return next.HandleSerialize(ctx, in)
+						}
 
-					checksum, err := computeDeleteObjectsContentMD5(input.Delete)
-					if err != nil {
-						return out, metadata, err
-					}
-					if checksum != "" {
-						ctx = middleware.WithStackValue(ctx, contentMD5StackKey{}, checksum)
-					}
+						checksum, err := computeDeleteObjectsContentMD5(input.Delete)
+						if err != nil {
+							return out, metadata, err
+						}
+						if checksum != "" {
+							ctx = middleware.WithStackValue(ctx, contentMD5StackKey{}, checksum)
+						}
 
-					return next.HandleSerialize(ctx, in)
-				},
-			),
-			middleware.Before,
-		); err != nil {
-			return err
+						return next.HandleSerialize(ctx, in)
+					},
+				),
+				middleware.Before,
+			); err != nil {
+				return err
+			}
 		}
 
 		if err := stack.Build.Add(
@@ -431,13 +436,19 @@ func litestreamAPIOption() func(*middleware.Stack) error {
 		// payload hashing. Switching to unsigned payload matches the behavior
 		// of the AWS SDK v1 client used in Litestream v0.3.x and restores
 		// compatibility.
-		_ = v4.RemoveComputePayloadSHA256Middleware(stack)
-		if err := v4.AddUnsignedPayloadMiddleware(stack); err != nil {
-			return err
+		if !c.SignPayload {
+			_ = v4.RemoveComputePayloadSHA256Middleware(stack)
+			if err := v4.AddUnsignedPayloadMiddleware(stack); err != nil {
+				return err
+			}
+			_ = v4.RemoveContentSHA256HeaderMiddleware(stack)
+			if err := v4.AddContentSHA256HeaderMiddleware(stack); err != nil {
+				return err
+			}
 		}
-		_ = v4.RemoveContentSHA256HeaderMiddleware(stack)
-		if err := v4.AddContentSHA256HeaderMiddleware(stack); err != nil {
-			return err
+
+		if !c.RequireContentMD5 {
+			return nil
 		}
 
 		md5Middleware := func() middleware.FinalizeMiddleware {



Test patch:
diff --git a/cmd/litestream/main.go b/cmd/litestream/main.go
index d770b69..5336c49 100644
--- a/cmd/litestream/main.go
+++ b/cmd/litestream/main.go
@@ -811,15 +811,17 @@ type ReplicaSettings struct {
 	ValidationInterval *time.Duration `yaml:"validation-interval"`
 
 	// S3 settings
-	AccessKeyID     string    `yaml:"access-key-id"`
-	SecretAccessKey string    `yaml:"secret-access-key"`
-	Region          string    `yaml:"region"`
-	Bucket          string    `yaml:"bucket"`
-	Endpoint        string    `yaml:"endpoint"`
-	ForcePathStyle  *bool     `yaml:"force-path-style"`
-	SkipVerify      bool      `yaml:"skip-verify"`
-	PartSize        *ByteSize `yaml:"part-size"`
-	Concurrency     *int      `yaml:"concurrency"`
+	AccessKeyID       string    `yaml:"access-key-id"`
+	SecretAccessKey   string    `yaml:"secret-access-key"`
+	Region            string    `yaml:"region"`
+	Bucket            string    `yaml:"bucket"`
+	Endpoint          string    `yaml:"endpoint"`
+	ForcePathStyle    *bool     `yaml:"force-path-style"`
+	SignPayload       *bool     `yaml:"sign-payload"`
+	RequireContentMD5 *bool     `yaml:"require-content-md5"`
+	SkipVerify        bool      `yaml:"skip-verify"`
+	PartSize          *ByteSize `yaml:"part-size"`
+	Concurrency       *int      `yaml:"concurrency"`
 
 	// ABS settings
 	AccountName string `yaml:"account-name"`
@@ -894,6 +896,12 @@ func (rs *ReplicaSettings) SetDefaults(src *ReplicaSettings) {
 	if rs.ForcePathStyle == nil {
 		rs.ForcePathStyle = src.ForcePathStyle
 	}
+	if rs.SignPayload == nil {
+		rs.SignPayload = src.SignPayload
+	}
+	if rs.RequireContentMD5 == nil {
+		rs.RequireContentMD5 = src.RequireContentMD5
+	}
 	if src.SkipVerify {
 		rs.SkipVerify = true
 	}
@@ -1101,6 +1109,14 @@ func NewS3ReplicaClientFromConfig(c *ReplicaConfig, _ *litestream.Replica) (_ *s
 
 	bucket, configPath := c.Bucket, c.Path
 	region, endpoint, skipVerify := c.Region, c.Endpoint, c.SkipVerify
+	signPayload := false
+	if v := c.SignPayload; v != nil {
+		signPayload = *v
+	}
+	requireContentMD5 := true
+	if v := c.RequireContentMD5; v != nil {
+		requireContentMD5 = *v
+	}
 
 	// Use path style if an endpoint is explicitly set. This works because the
 	// only service to not use path style is AWS which does not use an endpoint.
@@ -1117,10 +1133,14 @@ func NewS3ReplicaClientFromConfig(c *ReplicaConfig, _ *litestream.Replica) (_ *s
 		}
 
 		var (
-			ubucket         string
-			uregion         string
-			uendpoint       string
-			uforcePathStyle bool
+			ubucket               string
+			uregion               string
+			uendpoint             string
+			uforcePathStyle       bool
+			usignPayload          bool
+			usignPayloadSet       bool
+			urequireContentMD5    bool
+			urequireContentMD5Set bool
 		)
 
 		if strings.HasPrefix(host, "arn:") {
@@ -1152,6 +1172,14 @@ func NewS3ReplicaClientFromConfig(c *ReplicaConfig, _ *litestream.Replica) (_ *s
 		if qSkipVerify := query.Get("skipVerify"); qSkipVerify != "" {
 			skipVerify = qSkipVerify == "true"
 		}
+		if v, ok := boolQueryValue(query, "signPayload", "sign-payload"); ok {
+			usignPayload = v
+			usignPayloadSet = true
+		}
+		if v, ok := boolQueryValue(query, "requireContentMD5", "require-content-md5"); ok {
+			urequireContentMD5 = v
+			urequireContentMD5Set = true
+		}
 
 		// Only apply URL parts to field that have not been overridden.
 		if configPath == "" {
@@ -1169,6 +1197,12 @@ func NewS3ReplicaClientFromConfig(c *ReplicaConfig, _ *litestream.Replica) (_ *s
 		if !forcePathStyle {
 			forcePathStyle = uforcePathStyle
 		}
+		if c.SignPayload == nil && usignPayloadSet {
+			signPayload = usignPayload
+		}
+		if c.RequireContentMD5 == nil && urequireContentMD5Set {
+			requireContentMD5 = urequireContentMD5
+		}
 	}
 
 	// Ensure required settings are set.
@@ -1186,6 +1220,8 @@ func NewS3ReplicaClientFromConfig(c *ReplicaConfig, _ *litestream.Replica) (_ *s
 	client.Endpoint = endpoint
 	client.ForcePathStyle = forcePathStyle
 	client.SkipVerify = skipVerify
+	client.SignPayload = signPayload
+	client.RequireContentMD5 = requireContentMD5
 
 	// Apply upload configuration if specified.
 	if c.PartSize != nil {
@@ -1570,6 +1606,25 @@ func cleanReplicaURLPath(p string) string {
 	return cleaned
 }
 
+func boolQueryValue(query url.Values, keys ...string) (bool, bool) {
+	if query == nil {
+		return false, false
+	}
+	for _, key := range keys {
+		if raw := query.Get(key); raw != "" {
+			switch strings.ToLower(raw) {
+			case "true", "1", "t", "yes":
+				return true, true
+			case "false", "0", "f", "no":
+				return false, true
+			default:
+				return false, true
+			}
+		}
+	}
+	return false, false
+}
+
 func regionFromS3ARN(arn string) string {
 	parts := strings.SplitN(arn, ":", 6)
 	if len(parts) >= 4 {
diff --git a/cmd/litestream/main_test.go b/cmd/litestream/main_test.go
index 56b9a06..4072390 100644
--- a/cmd/litestream/main_test.go
+++ b/cmd/litestream/main_test.go
@@ -2250,6 +2250,46 @@ func TestNewS3ReplicaClientFromConfig(t *testing.T) {
 			t.Error("expected ForcePathStyle to be true for custom endpoint")
 		}
 	})
+
+	t.Run("QuerySigningOptions", func(t *testing.T) {
+		config := &main.ReplicaConfig{
+			URL: "s3://bucket/db?sign-payload=true&require-content-md5=false",
+		}
+
+		client, err := main.NewS3ReplicaClientFromConfig(config, nil)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if !client.SignPayload {
+			t.Error("expected SignPayload to be true when query parameter is set")
+		}
+		if client.RequireContentMD5 {
+			t.Error("expected RequireContentMD5 to be false when disabled via query")
+		}
+	})
+
+	t.Run("ConfigOverridesQuerySigning", func(t *testing.T) {
+		signTrue := true
+		requireFalse := false
+		config := &main.ReplicaConfig{
+			URL: "s3://bucket/db?sign-payload=false&require-content-md5=true",
+			ReplicaSettings: main.ReplicaSettings{
+				SignPayload:       &signTrue,
+				RequireContentMD5: &requireFalse,
+			},
+		}
+
+		client, err := main.NewS3ReplicaClientFromConfig(config, nil)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if !client.SignPayload {
+			t.Error("expected config SignPayload to override query parameter")
+		}
+		if client.RequireContentMD5 {
+			t.Error("expected config RequireContentMD5=false to override query parameter")
+		}
+	})
 }
 func TestGlobalDefaults(t *testing.T) {
 	// Test comprehensive global defaults functionality
diff --git a/internal/testingutil/testingutil.go b/internal/testingutil/testingutil.go
index e417c35..af36998 100644
--- a/internal/testingutil/testingutil.go
+++ b/internal/testingutil/testingutil.go
@@ -29,6 +29,13 @@ import (
 	"github.com/benbjohnson/litestream/webdav"
 )
 
+const (
+	defaultTigrisEndpoint = "https://fly.storage.tigris.dev"
+	defaultTigrisRegion   = "auto"
+	defaultTigrisBucket   = "litestream-dev"
+	defaultTigrisPathRoot = "integration-tests"
+)
+
 var (
 	// Enables integration tests.
 	integration = flag.Bool("integration", false, "")
@@ -51,6 +58,12 @@ var (
 	s3SkipVerify      = flag.Bool("s3-skip-verify", os.Getenv("LITESTREAM_S3_SKIP_VERIFY") == "true", "")
 )
 
+// Tigris settings (S3-compatible)
+var (
+	tigrisAccessKeyID     = flag.String("tigris-access-key-id", os.Getenv("LITESTREAM_TIGRIS_ACCESS_KEY_ID"), "")
+	tigrisSecretAccessKey = flag.String("tigris-secret-access-key", os.Getenv("LITESTREAM_TIGRIS_SECRET_ACCESS_KEY"), "")
+)
+
 // Google cloud storage settings
 var (
 	gsBucket = flag.String("gs-bucket", os.Getenv("LITESTREAM_GS_BUCKET"), "")
@@ -202,6 +215,8 @@ func NewReplicaClient(tb testing.TB, typ string) litestream.ReplicaClient {
 		return NewWebDAVReplicaClient(tb)
 	case nats.ReplicaClientType:
 		return NewNATSReplicaClient(tb)
+	case "tigris":
+		return NewTigrisReplicaClient(tb)
 	default:
 		tb.Fatalf("invalid replica client type: %q", typ)
 		return nil
@@ -227,6 +242,28 @@ func NewS3ReplicaClient(tb testing.TB) *s3.ReplicaClient {
 	c.Endpoint = *s3Endpoint
 	c.ForcePathStyle = *s3ForcePathStyle
 	c.SkipVerify = *s3SkipVerify
+	c.SignPayload = true
+	return c
+}
+
+// NewTigrisReplicaClient returns an S3 client configured for Fly.io Tigris.
+func NewTigrisReplicaClient(tb testing.TB) *s3.ReplicaClient {
+	tb.Helper()
+
+	if *tigrisAccessKeyID == "" || *tigrisSecretAccessKey == "" {
+		tb.Fatalf("tigris credentials not configured (set LITESTREAM_TIGRIS_ACCESS_KEY_ID/SECRET_ACCESS_KEY)")
+	}
+
+	c := s3.NewReplicaClient()
+	c.AccessKeyID = *tigrisAccessKeyID
+	c.SecretAccessKey = *tigrisSecretAccessKey
+	c.Region = defaultTigrisRegion
+	c.Bucket = defaultTigrisBucket
+	c.Path = path.Join(defaultTigrisPathRoot, fmt.Sprintf("%016x", rand.Uint64()))
+	c.Endpoint = defaultTigrisEndpoint
+	c.ForcePathStyle = true
+	c.SignPayload = true
+	c.RequireContentMD5 = false
 	return c
 }
 
diff --git a/replica_client_test.go b/replica_client_test.go
index c4dfd46..d54b895 100644
--- a/replica_client_test.go
+++ b/replica_client_test.go
@@ -22,13 +22,23 @@ import (
 // createLTXData creates a minimal valid LTX file with a header for testing.
 // The data parameter is appended after the header for testing purposes.
 func createLTXData(minTXID, maxTXID ltx.TXID, data []byte) []byte {
+	return createLTXDataWithTimestamp(minTXID, maxTXID, time.Now(), data)
+}
+
+func createLTXDataWithTimestamp(minTXID, maxTXID ltx.TXID, ts time.Time, data []byte) []byte {
 	hdr := ltx.Header{
-		Version:   1,
+		Version:   ltx.Version,
 		PageSize:  4096,
 		Commit:    1,
 		MinTXID:   minTXID,
 		MaxTXID:   maxTXID,
-		Timestamp: time.Now().UnixMilli(),
+		Timestamp: ts.UnixMilli(),
+	}
+	if minTXID == 1 {
+		// Snapshot files do not include a checksum.
+		hdr.PreApplyChecksum = 0
+	} else {
+		hdr.PreApplyChecksum = ltx.ChecksumFlag
 	}
 
 	headerBytes, _ := hdr.MarshalBinary()
@@ -244,27 +254,8 @@ func TestReplicaClient_TimestampPreservation(t *testing.T) {
 		// Use a timestamp from the past to ensure it's different from write time
 		expectedTimestamp := time.Now().Add(-1 * time.Hour).Truncate(time.Millisecond)
 
-		// Create a minimal LTX file with header containing the timestamp
-		hdr := ltx.Header{
-			Version:   1,
-			PageSize:  4096,
-			Commit:    1,
-			MinTXID:   1,
-			MaxTXID:   1,
-			Timestamp: expectedTimestamp.UnixMilli(),
-		}
-
-		// Marshal LTX header
-		headerBytes, err := hdr.MarshalBinary()
-		if err != nil {
-			t.Fatal(err)
-		}
-
-		// Combine header with minimal body to create valid LTX file
-		buf := bytes.NewReader(headerBytes)
-
-		// Write to storage backend
-		info, err := c.WriteLTXFile(ctx, 0, ltx.TXID(1), ltx.TXID(1), buf)
+		ltxData := createLTXDataWithTimestamp(1, 1, expectedTimestamp, []byte("payload"))
+		info, err := c.WriteLTXFile(ctx, 0, ltx.TXID(1), ltx.TXID(1), bytes.NewReader(ltxData))
 		if err != nil {
 			t.Fatal(err)
 		}
@@ -277,7 +268,7 @@ func TestReplicaClient_TimestampPreservation(t *testing.T) {
 		}
 
 		// Read back via LTXFiles and verify timestamp is preserved
-		itr, err := c.LTXFiles(ctx, 0, 0, false)
+		itr, err := c.LTXFiles(ctx, 0, 0, true)
 		if err != nil {
 			t.Fatal(err)
 		}
@@ -299,11 +290,12 @@ func TestReplicaClient_TimestampPreservation(t *testing.T) {
 			t.Fatal("LTX file not found in iteration")
 		}
 
+		// All backends preserve timestamps in metadata (see issue #771)
 		// Verify timestamp was preserved (allow 1 second drift for precision)
 		timeDiff := found.CreatedAt.Sub(expectedTimestamp)
 		if timeDiff.Abs() > time.Second {
-			t.Errorf("Timestamp not preserved: expected %v, got %v (diff: %v)",
-				expectedTimestamp, found.CreatedAt, timeDiff)
+			t.Errorf("Timestamp not preserved for backend %T: expected %v, got %v (diff: %v)",
+				c, expectedTimestamp, found.CreatedAt, timeDiff)
 		}
 	})
 }
@@ -342,13 +334,14 @@ func TestReplicaClient_S3_UploaderConfig(t *testing.T) {
 		} else {
 			t.Log("Using 10MB file size to test multipart upload")
 		}
-		data := make([]byte, size)
-		for i := range data {
-			data[i] = byte(i % 256)
+		payload := make([]byte, size)
+		for i := range payload {
+			payload[i] = byte(i % 256)
 		}
+		ltxData := createLTXData(1, 100, payload)
 
 		// Upload the file using bytes.Reader to avoid string conversion issues
-		if _, err := c.WriteLTXFile(context.Background(), 0, ltx.TXID(1), ltx.TXID(100), bytes.NewReader(data)); err != nil {
+		if _, err := c.WriteLTXFile(context.Background(), 0, ltx.TXID(1), ltx.TXID(100), bytes.NewReader(ltxData)); err != nil {
 			t.Fatalf("failed to write large file: %v", err)
 		}
 
@@ -364,12 +357,12 @@ func TestReplicaClient_S3_UploaderConfig(t *testing.T) {
 			t.Fatalf("failed to read large file: %v", err)
 		}
 
-		if len(buf) != size {
-			t.Errorf("size mismatch: got %d, want %d", len(buf), size)
+		if len(buf) != len(ltxData) {
+			t.Errorf("size mismatch: got %d, want %d", len(buf), len(ltxData))
 		}
 
 		// Verify the data matches what we uploaded
-		if !bytes.Equal(buf, data) {
+		if !bytes.Equal(buf, ltxData) {
 			t.Errorf("data mismatch: uploaded and downloaded data do not match")
 		}
 	})
diff --git a/s3/replica_client_test.go b/s3/replica_client_test.go
index 0bf3a48..c9c8057 100644
--- a/s3/replica_client_test.go
+++ b/s3/replica_client_test.go
@@ -4,7 +4,9 @@ import (
 	"bytes"
 	"context"
 	"crypto/md5"
+	"crypto/sha256"
 	"encoding/base64"
+	"encoding/hex"
 	"errors"
 	"io"
 	"log/slog"
@@ -85,52 +87,69 @@ func TestIsNotExists(t *testing.T) {
 	}
 }
 
-func TestReplicaClientUnsignedPayload(t *testing.T) {
-	headers := make(chan http.Header, 1)
-	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		defer r.Body.Close()
-		_, _ = io.Copy(io.Discard, r.Body)
+func TestReplicaClientPayloadSigning(t *testing.T) {
+	data := mustLTX(t)
+	signedPayload := sha256.Sum256(data)
+	wantSigned := hex.EncodeToString(signedPayload[:])
 
-		if r.Method == http.MethodPut {
-			select {
-			case headers <- r.Header.Clone():
-			default:
-			}
-			w.Header().Set("ETag", `"test-etag"`)
-			w.WriteHeader(http.StatusOK)
-			return
-		}
-
-		w.WriteHeader(http.StatusOK)
-	}))
-	defer server.Close()
-
-	client := NewReplicaClient()
-	client.Bucket = "test-bucket"
-	client.Path = "replica"
-	client.Region = "us-east-1"
-	client.Endpoint = server.URL
-	client.ForcePathStyle = true
-	client.AccessKeyID = "test-access-key"
-	client.SecretAccessKey = "test-secret-key"
-
-	ctx := context.Background()
-	if err := client.Init(ctx); err != nil {
-		t.Fatalf("Init() error: %v", err)
+	tests := []struct {
+		name        string
+		signPayload bool
+		wantHeader  string
+	}{
+		{name: "UnsignedByDefault", signPayload: false, wantHeader: "UNSIGNED-PAYLOAD"},
+		{name: "SignedWhenEnabled", signPayload: true, wantHeader: wantSigned},
 	}
 
-	data := mustLTX(t)
-	if _, err := client.WriteLTXFile(ctx, 0, 2, 2, bytes.NewReader(data)); err != nil {
-		t.Fatalf("WriteLTXFile() error: %v", err)
-	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			headers := make(chan http.Header, 1)
+			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				defer r.Body.Close()
+				_, _ = io.Copy(io.Discard, r.Body)
+
+				if r.Method == http.MethodPut {
+					select {
+					case headers <- r.Header.Clone():
+					default:
+					}
+					w.Header().Set("ETag", `"test-etag"`)
+					w.WriteHeader(http.StatusOK)
+					return
+				}
+
+				w.WriteHeader(http.StatusOK)
+			}))
+			defer server.Close()
+
+			client := NewReplicaClient()
+			client.Bucket = "test-bucket"
+			client.Path = "replica"
+			client.Region = "us-east-1"
+			client.Endpoint = server.URL
+			client.ForcePathStyle = true
+			client.AccessKeyID = "test-access-key"
+			client.SecretAccessKey = "test-secret-key"
+			client.SignPayload = tt.signPayload
+
+			ctx := context.Background()
+			if err := client.Init(ctx); err != nil {
+				t.Fatalf("Init() error: %v", err)
+			}
 
-	select {
-	case hdr := <-headers:
-		if got, want := hdr.Get("x-amz-content-sha256"), "UNSIGNED-PAYLOAD"; got != want {
-			t.Fatalf("x-amz-content-sha256 header = %q, want %q", got, want)
-		}
-	case <-time.After(time.Second):
-		t.Fatal("timeout waiting for signed PUT request")
+			if _, err := client.WriteLTXFile(ctx, 0, 2, 2, bytes.NewReader(data)); err != nil {
+				t.Fatalf("WriteLTXFile() error: %v", err)
+			}
+
+			select {
+			case hdr := <-headers:
+				if got, want := hdr.Get("x-amz-content-sha256"), tt.wantHeader; got != want {
+					t.Fatalf("x-amz-content-sha256 header = %q, want %q", got, want)
+				}
+			case <-time.After(time.Second):
+				t.Fatal("timeout waiting for PUT request")
+			}
+		})
 	}
 }
 
@@ -305,75 +324,114 @@ func TestReplicaClient_HTTPClientConfiguration(t *testing.T) {
 }
 
 func TestReplicaClientDeleteLTXFiles_ContentMD5(t *testing.T) {
-	var callCount int
+	t.Run("Enabled", func(t *testing.T) {
+		var callCount int
 
-	httpClient := smithyhttp.ClientDoFunc(func(r *http.Request) (*http.Response, error) {
-		t.Helper()
-		callCount++
+		httpClient := smithyhttp.ClientDoFunc(func(r *http.Request) (*http.Response, error) {
+			t.Helper()
+			callCount++
 
-		if r.Method != http.MethodPost {
-			t.Fatalf("unexpected method: %s", r.Method)
-		}
-		if !strings.Contains(r.URL.RawQuery, "delete") {
-			t.Fatalf("unexpected query: %s", r.URL.RawQuery)
-		}
+			if r.Method != http.MethodPost {
+				t.Fatalf("unexpected method: %s", r.Method)
+			}
+			if !strings.Contains(r.URL.RawQuery, "delete") {
+				t.Fatalf("unexpected query: %s", r.URL.RawQuery)
+			}
 
-		if ua := r.Header.Get("User-Agent"); !strings.Contains(ua, "litestream") {
-			t.Fatalf("expected User-Agent to contain litestream, got %q", ua)
-		}
+			if ua := r.Header.Get("User-Agent"); !strings.Contains(ua, "litestream") {
+				t.Fatalf("expected User-Agent to contain litestream, got %q", ua)
+			}
 
-		body, err := io.ReadAll(r.Body)
-		if err != nil {
-			t.Fatalf("read body: %v", err)
-		}
-		r.Body.Close()
+			body, err := io.ReadAll(r.Body)
+			if err != nil {
+				t.Fatalf("read body: %v", err)
+			}
+			r.Body.Close()
 
-		got := r.Header.Get("Content-MD5")
-		if got == "" {
-			t.Fatal("expected Content-MD5 header")
+			got := r.Header.Get("Content-MD5")
+			if got == "" {
+				t.Fatal("expected Content-MD5 header")
+			}
+
+			sum := md5.Sum(body)
+			want := base64.StdEncoding.EncodeToString(sum[:])
+			if got != want {
+				t.Fatalf("unexpected Content-MD5 header: got %q, want %q", got, want)
+			}
+
+			resp := &http.Response{
+				StatusCode: http.StatusOK,
+				Header:     http.Header{"Content-Type": []string{"application/xml"}},
+				Body: io.NopCloser(strings.NewReader(
+					`<DeleteResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"></DeleteResult>`,
+				)),
+			}
+			return resp, nil
+		})
+
+		cfg := aws.Config{
+			Region:      "us-east-1",
+			Credentials: aws.NewCredentialsCache(aws.AnonymousCredentials{}),
+			HTTPClient:  httpClient,
 		}
 
-		sum := md5.Sum(body)
-		want := base64.StdEncoding.EncodeToString(sum[:])
-		if got != want {
-			t.Fatalf("unexpected Content-MD5 header: got %q, want %q", got, want)
+		c := NewReplicaClient()
+		c.logger = slog.New(slog.NewTextHandler(io.Discard, nil))
+		c.s3 = s3.NewFromConfig(cfg, func(o *s3.Options) {
+			o.APIOptions = append(o.APIOptions, c.middlewareOption())
+		})
+		c.Bucket = "test-bucket"
+		c.Path = "test-path"
+
+		files := []*ltx.FileInfo{
+			{Level: 0, MinTXID: 1, MaxTXID: 1},
+			{Level: 0, MinTXID: 2, MaxTXID: 2},
 		}
 
-		resp := &http.Response{
-			StatusCode: http.StatusOK,
-			Header:     http.Header{"Content-Type": []string{"application/xml"}},
-			Body: io.NopCloser(strings.NewReader(
-				`<DeleteResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"></DeleteResult>`,
-			)),
+		if err := c.DeleteLTXFiles(context.Background(), files); err != nil {
+			t.Fatalf("DeleteLTXFiles: %v", err)
+		}
+		if callCount != 1 {
+			t.Fatalf("unexpected call count: %d", callCount)
 		}
-		return resp, nil
 	})
 
-	cfg := aws.Config{
-		Region:      "us-east-1",
-		Credentials: aws.NewCredentialsCache(aws.AnonymousCredentials{}),
-		HTTPClient:  httpClient,
-	}
+	t.Run("Disabled", func(t *testing.T) {
+		httpClient := smithyhttp.ClientDoFunc(func(r *http.Request) (*http.Response, error) {
+			t.Helper()
+			if md5Header := r.Header.Get("Content-MD5"); md5Header != "" {
+				t.Fatalf("expected Content-MD5 header to be empty when disabled, got %q", md5Header)
+			}
+			resp := &http.Response{
+				StatusCode: http.StatusOK,
+				Header:     http.Header{"Content-Type": []string{"application/xml"}},
+				Body: io.NopCloser(strings.NewReader(
+					`<DeleteResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"></DeleteResult>`,
+				)),
+			}
+			return resp, nil
+		})
 
-	c := NewReplicaClient()
-	c.logger = slog.New(slog.NewTextHandler(io.Discard, nil))
-	c.s3 = s3.NewFromConfig(cfg, func(o *s3.Options) {
-		o.APIOptions = append(o.APIOptions, litestreamAPIOption())
-	})
-	c.Bucket = "test-bucket"
-	c.Path = "test-path"
+		cfg := aws.Config{
+			Region:      "us-east-1",
+			Credentials: aws.NewCredentialsCache(aws.AnonymousCredentials{}),
+			HTTPClient:  httpClient,
+		}
 
-	files := []*ltx.FileInfo{
-		{Level: 0, MinTXID: 1, MaxTXID: 1},
-		{Level: 0, MinTXID: 2, MaxTXID: 2},
-	}
+		c := NewReplicaClient()
+		c.RequireContentMD5 = false
+		c.logger = slog.New(slog.NewTextHandler(io.Discard, nil))
+		c.s3 = s3.NewFromConfig(cfg, func(o *s3.Options) {
+			o.APIOptions = append(o.APIOptions, c.middlewareOption())
+		})
+		c.Bucket = "test-bucket"
+		c.Path = "test-path"
 
-	if err := c.DeleteLTXFiles(context.Background(), files); err != nil {
-		t.Fatalf("DeleteLTXFiles: %v", err)
-	}
-	if callCount != 1 {
-		t.Fatalf("unexpected call count: %d", callCount)
-	}
+		files := []*ltx.FileInfo{{Level: 0, MinTXID: 1, MaxTXID: 1}}
+		if err := c.DeleteLTXFiles(context.Background(), files); err != nil {
+			t.Fatalf("DeleteLTXFiles: %v", err)
+		}
+	})
 }
 
 func TestReplicaClientDeleteLTXFiles_PreexistingContentMD5(t *testing.T) {
@@ -408,7 +466,7 @@ func TestReplicaClientDeleteLTXFiles_PreexistingContentMD5(t *testing.T) {
 	c := NewReplicaClient()
 	c.logger = slog.New(slog.NewTextHandler(io.Discard, nil))
 	c.s3 = s3.NewFromConfig(cfg, func(o *s3.Options) {
-		o.APIOptions = append(o.APIOptions, litestreamAPIOption())
+		o.APIOptions = append(o.APIOptions, c.middlewareOption())
 		o.APIOptions = append(o.APIOptions, func(stack *middleware.Stack) error {
 			return stack.Finalize.Add(
 				middleware.FinalizeMiddlewareFunc(