Repository: benbjohnson/litestream

Issue:
cannot verify wal state: prev WAL offset is less than the header size: -4088
Litestream fails with `cannot verify wal state: prev WAL offset is less than the header size: -4088` when an LTX file has `WALOffset=WALHeaderSize (32)` and `WALSize=0`.

The verify function in `db.go` calculates `prevWALOffset = info.offset - frameSize` before checking boundary conditions. When `info.offset == 32` and `frameSize == 4120` (4096 page + 24 frame header), this results in `-4088`, triggering the error at line 896.

## Environment

**Litestream version:**
```text
0.5.3
```

**Operating system & version:**
Linux

## Logs

```text
cannot verify wal state: prev WAL offset is less than the header size: -4088
```

Hints from issue comments:

Solution patch:
diff --git a/db.go b/db.go
index b3c7824..19ed4d1 100644
--- a/db.go
+++ b/db.go
@@ -65,6 +65,12 @@ type DB struct {
 	notify   chan struct{} // closes on WAL change
 	chkMu    sync.RWMutex  // checkpoint lock
 
+	// syncedSinceCheckpoint tracks whether any data has been synced since
+	// the last checkpoint. Used to prevent time-based checkpoints from
+	// triggering when there are no actual database changes, which would
+	// otherwise create unnecessary LTX files. See issue #896.
+	syncedSinceCheckpoint bool
+
 	// last file info for each level
 	maxLTXFileInfos struct {
 		sync.Mutex
@@ -598,11 +604,16 @@ func (db *DB) Sync(ctx context.Context) (err error) {
 		return fmt.Errorf("ensure wal exists: %w", err)
 	}
 
-	origWALSize, newWALSize, err := db.verifyAndSync(ctx, false)
+	origWALSize, newWALSize, synced, err := db.verifyAndSync(ctx, false)
 	if err != nil {
 		return err
 	}
 
+	// Track that data was synced for time-based checkpoint decisions.
+	if synced {
+		db.syncedSinceCheckpoint = true
+	}
+
 	if err := db.checkpointIfNeeded(ctx, origWALSize, newWALSize); err != nil {
 		return fmt.Errorf("checkpoint: %w", err)
 	}
@@ -629,11 +640,11 @@ func (db *DB) Sync(ctx context.Context) (err error) {
 	return nil
 }
 
-func (db *DB) verifyAndSync(ctx context.Context, checkpointing bool) (origWALSize, newWALSize int64, err error) {
+func (db *DB) verifyAndSync(ctx context.Context, checkpointing bool) (origWALSize, newWALSize int64, synced bool, err error) {
 	// Capture WAL size before sync for checkpoint threshold checks.
 	origWALSize, err = db.walFileSize()
 	if err != nil {
-		return 0, 0, fmt.Errorf("stat wal before sync: %w", err)
+		return 0, 0, false, fmt.Errorf("stat wal before sync: %w", err)
 	}
 
 	// Verify our last sync matches the current state of the WAL.
@@ -641,26 +652,32 @@ func (db *DB) verifyAndSync(ctx context.Context, checkpointing bool) (origWALSiz
 	// been overwritten by another process.
 	info, err := db.verify(ctx)
 	if err != nil {
-		return 0, 0, fmt.Errorf("cannot verify wal state: %w", err)
+		return 0, 0, false, fmt.Errorf("cannot verify wal state: %w", err)
 	}
 
-	if err := db.sync(ctx, checkpointing, info); err != nil {
-		return 0, 0, fmt.Errorf("sync: %w", err)
+	synced, err = db.sync(ctx, checkpointing, info)
+	if err != nil {
+		return 0, 0, false, fmt.Errorf("sync: %w", err)
 	}
 
 	// Capture WAL size after sync for checkpoint threshold checks.
 	newWALSize, err = db.walFileSize()
 	if err != nil {
-		return 0, 0, fmt.Errorf("stat wal after sync: %w", err)
+		return 0, 0, false, fmt.Errorf("stat wal after sync: %w", err)
 	}
 
-	return origWALSize, newWALSize, nil
+	return origWALSize, newWALSize, synced, nil
 }
 
 // checkpointIfNeeded performs a checkpoint based on configured thresholds.
 // Checks thresholds in priority order: TruncatePageN → MinCheckpointPageN → CheckpointInterval.
 //
 // TruncatePageN uses TRUNCATE mode (blocking), others use PASSIVE mode (non-blocking).
+//
+// Time-based checkpoints only trigger if db.syncedSinceCheckpoint is true, indicating
+// that data has been synced since the last checkpoint. This prevents creating unnecessary
+// LTX files when the only WAL data is from internal bookkeeping (like _litestream_seq
+// updates from previous checkpoints). See issue #896.
 func (db *DB) checkpointIfNeeded(ctx context.Context, origWALSize, newWALSize int64) error {
 	if db.pageSize == 0 {
 		return nil
@@ -690,7 +707,10 @@ func (db *DB) checkpointIfNeeded(ctx context.Context, origWALSize, newWALSize in
 	}
 
 	// Priority 3: Time-based checkpoint (PASSIVE mode, non-blocking)
-	if db.CheckpointInterval > 0 {
+	// Only trigger if there have been actual changes synced since the last
+	// checkpoint. This prevents creating unnecessary LTX files when the only
+	// WAL data is from internal bookkeeping (like _litestream_seq updates).
+	if db.CheckpointInterval > 0 && db.syncedSinceCheckpoint {
 		// Get database file modification time
 		fi, err := db.f.Stat()
 		if err != nil {
@@ -885,6 +905,21 @@ func (db *DB) verify(ctx context.Context) (info syncInfo, err error) {
 	salt2 := binary.BigEndian.Uint32(hdr0[20:])
 	saltMatch := salt1 == dec.Header().WALSalt1 && salt2 == dec.Header().WALSalt2
 
+	// Handle edge case where we're at WAL header (WALOffset=32, WALSize=0).
+	// This can happen when an LTX file represents a state at the beginning of the WAL
+	// with no frames written yet. We must check this before computing prevWALOffset
+	// to avoid underflow (32 - 4120 = -4088).
+	// See: https://github.com/benbjohnson/litestream/issues/900
+	if info.offset == WALHeaderSize {
+		slog.Debug("verify", "saltMatch", saltMatch, "atWALHeader", true)
+		if saltMatch {
+			info.snapshotting = false
+			return info, nil
+		}
+		info.reason = "wal header salt reset, snapshotting"
+		return info, nil
+	}
+
 	// If offset is at the beginning of the first page, we can't check for previous page.
 	prevWALOffset := info.offset - frameSize
 	slog.Debug("verify", "saltMatch", saltMatch, "prevWALOffset", prevWALOffset)
@@ -1018,11 +1053,12 @@ type syncInfo struct {
 }
 
 // sync copies pending bytes from the real WAL to LTX.
-func (db *DB) sync(ctx context.Context, checkpointing bool, info syncInfo) error {
+// Returns synced=true if an LTX file was created (i.e., there were new pages to sync).
+func (db *DB) sync(ctx context.Context, checkpointing bool, info syncInfo) (synced bool, err error) {
 	// Determine the next sequential transaction ID.
 	pos, err := db.Pos()
 	if err != nil {
-		return fmt.Errorf("pos: %w", err)
+		return false, fmt.Errorf("pos: %w", err)
 	}
 	txID := pos.TXID + 1
 
@@ -1051,21 +1087,21 @@ func (db *DB) sync(ctx context.Context, checkpointing bool, info syncInfo) error
 
 	fi, err := db.f.Stat()
 	if err != nil {
-		return err
+		return false, err
 	}
 	mode := fi.Mode()
 	commit := uint32(fi.Size() / int64(db.pageSize))
 
 	walFile, err := os.Open(db.WALPath())
 	if err != nil {
-		return err
+		return false, err
 	}
 	defer walFile.Close()
 
 	var rd *WALReader
 	if info.offset == WALHeaderSize {
 		if rd, err = NewWALReader(walFile, db.Logger); err != nil {
-			return fmt.Errorf("new wal reader: %w", err)
+			return false, fmt.Errorf("new wal reader: %w", err)
 		}
 	} else {
 		// If we cannot verify the previous frame
@@ -1074,17 +1110,17 @@ func (db *DB) sync(ctx context.Context, checkpointing bool, info syncInfo) error
 			db.Logger.Log(ctx, internal.LevelTrace, "prev frame mismatch, snapshotting", "err", pfmError.Err)
 			info.offset = WALHeaderSize
 			if rd, err = NewWALReader(walFile, db.Logger); err != nil {
-				return fmt.Errorf("new wal reader, after reset")
+				return false, fmt.Errorf("new wal reader, after reset")
 			}
 		} else if err != nil {
-			return fmt.Errorf("new wal reader with offset: %w", err)
+			return false, fmt.Errorf("new wal reader with offset: %w", err)
 		}
 	}
 
 	// Build a mapping of changed page numbers and their latest content.
 	pageMap, maxOffset, walCommit, err := rd.PageMap(ctx)
 	if err != nil {
-		return fmt.Errorf("page map: %w", err)
+		return false, fmt.Errorf("page map: %w", err)
 	}
 	if walCommit > 0 {
 		commit = walCommit
@@ -1104,17 +1140,17 @@ func (db *DB) sync(ctx context.Context, checkpointing bool, info syncInfo) error
 	// Exit if we have no new WAL pages and we aren't snapshotting.
 	if !info.snapshotting && sz == 0 {
 		db.Logger.Log(ctx, internal.LevelTrace, "sync: skip", "reason", "no new wal pages")
-		return nil
+		return false, nil
 	}
 
 	tmpFilename := filename + ".tmp"
 	if err := internal.MkdirAll(filepath.Dir(tmpFilename), db.dirInfo); err != nil {
-		return err
+		return false, err
 	}
 
 	ltxFile, err := os.OpenFile(tmpFilename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, mode)
 	if err != nil {
-		return fmt.Errorf("open temp ltx file: %w", err)
+		return false, fmt.Errorf("open temp ltx file: %w", err)
 	}
 	defer func() { _ = os.Remove(tmpFilename) }()
 	defer func() { _ = ltxFile.Close() }()
@@ -1133,7 +1169,7 @@ func (db *DB) sync(ctx context.Context, checkpointing bool, info syncInfo) error
 	timestamp := time.Now()
 	enc, err := ltx.NewEncoder(ltxFile)
 	if err != nil {
-		return fmt.Errorf("new ltx encoder: %w", err)
+		return false, fmt.Errorf("new ltx encoder: %w", err)
 	}
 	if err := enc.EncodeHeader(ltx.Header{
 		Version:   ltx.Version,
@@ -1148,32 +1184,32 @@ func (db *DB) sync(ctx context.Context, checkpointing bool, info syncInfo) error
 		WALSalt1:  rd.salt1,
 		WALSalt2:  rd.salt2,
 	}); err != nil {
-		return fmt.Errorf("encode ltx header: %w", err)
+		return false, fmt.Errorf("encode ltx header: %w", err)
 	}
 
 	// If we need a full snapshot, then copy from the database & WAL.
 	// Otherwise, just copy incrementally from the WAL.
 	if info.snapshotting {
 		if err := db.writeLTXFromDB(ctx, enc, walFile, commit, pageMap); err != nil {
-			return fmt.Errorf("write ltx from db: %w", err)
+			return false, fmt.Errorf("write ltx from db: %w", err)
 		}
 	} else {
 		if err := db.writeLTXFromWAL(ctx, enc, walFile, pageMap); err != nil {
-			return fmt.Errorf("write ltx from wal: %w", err)
+			return false, fmt.Errorf("write ltx from wal: %w", err)
 		}
 	}
 
 	// Encode final trailer to the end of the LTX file.
 	if err := enc.Close(); err != nil {
-		return fmt.Errorf("close ltx encoder: %w", err)
+		return false, fmt.Errorf("close ltx encoder: %w", err)
 	}
 
 	// Sync & close LTX file.
 	if err := ltxFile.Sync(); err != nil {
-		return fmt.Errorf("sync ltx file: %w", err)
+		return false, fmt.Errorf("sync ltx file: %w", err)
 	}
 	if err := ltxFile.Close(); err != nil {
-		return fmt.Errorf("close ltx file: %w", err)
+		return false, fmt.Errorf("close ltx file: %w", err)
 	}
 
 	// Atomically rename file to final path.
@@ -1181,7 +1217,7 @@ func (db *DB) sync(ctx context.Context, checkpointing bool, info syncInfo) error
 		db.maxLTXFileInfos.Lock()
 		delete(db.maxLTXFileInfos.m, 0) // clear cache if in unknown state
 		db.maxLTXFileInfos.Unlock()
-		return fmt.Errorf("rename ltx file: %w", err)
+		return false, fmt.Errorf("rename ltx file: %w", err)
 	}
 
 	// Update file info cache for L0.
@@ -1197,7 +1233,7 @@ func (db *DB) sync(ctx context.Context, checkpointing bool, info syncInfo) error
 
 	db.Logger.Debug("db sync", "status", "ok")
 
-	return nil
+	return true, nil
 }
 
 func (db *DB) writeLTXFromDB(ctx context.Context, enc *ltx.Encoder, walFile *os.File, commit uint32, pageMap map[uint32]int64) error {
@@ -1299,7 +1335,7 @@ func (db *DB) checkpoint(ctx context.Context, mode string) error {
 	}
 
 	// Copy end of WAL before checkpoint to copy as much as possible.
-	if _, _, err := db.verifyAndSync(ctx, true); err != nil {
+	if _, _, _, err := db.verifyAndSync(ctx, true); err != nil {
 		return fmt.Errorf("cannot copy wal before checkpoint: %w", err)
 	}
 
@@ -1315,6 +1351,7 @@ func (db *DB) checkpoint(ctx context.Context, mode string) error {
 	if other, err := readWALHeader(db.WALPath()); err != nil {
 		return err
 	} else if bytes.Equal(hdr, other) {
+		db.syncedSinceCheckpoint = false
 		return nil
 	}
 
@@ -1334,7 +1371,7 @@ func (db *DB) checkpoint(ctx context.Context, mode string) error {
 	}
 
 	// Copy anything that may have occurred after the checkpoint.
-	if _, _, err := db.verifyAndSync(ctx, true); err != nil {
+	if _, _, _, err := db.verifyAndSync(ctx, true); err != nil {
 		return fmt.Errorf("cannot copy wal after checkpoint: %w", err)
 	}
 
@@ -1342,6 +1379,8 @@ func (db *DB) checkpoint(ctx context.Context, mode string) error {
 	if err := tx.Rollback(); err != nil {
 		return fmt.Errorf("rollback post-checkpoint tx: %w", err)
 	}
+
+	db.syncedSinceCheckpoint = false
 	return nil
 }
 
diff --git a/docker-compose.test.yml b/docker-compose.test.yml
new file mode 100644
index 0000000..1cb048c
--- /dev/null
+++ b/docker-compose.test.yml
@@ -0,0 +1,15 @@
+services:
+  minio:
+    image: minio/minio:latest
+    ports:
+      - "9000:9000"
+      - "9001:9001"
+    environment:
+      MINIO_ROOT_USER: minioadmin
+      MINIO_ROOT_PASSWORD: minioadmin
+    command: server /data --console-address ":9001"
+    healthcheck:
+      test: ["CMD", "mc", "ready", "local"]
+      interval: 5s
+      timeout: 5s
+      retries: 5
diff --git a/etc/run-s3-docker-tests.sh b/etc/run-s3-docker-tests.sh
new file mode 100755
index 0000000..e9245f9
--- /dev/null
+++ b/etc/run-s3-docker-tests.sh
@@ -0,0 +1,55 @@
+#!/bin/bash
+set -e
+
+# Script to run S3 integration tests against a local MinIO container.
+# This provides a more realistic test environment than moto for testing
+# S3-compatible provider compatibility.
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
+
+cd "$PROJECT_ROOT"
+
+echo "Starting MinIO container..."
+docker compose -f docker-compose.test.yml up -d
+
+# Wait for MinIO to be ready
+echo "Waiting for MinIO to be ready..."
+for i in {1..30}; do
+    if docker compose -f docker-compose.test.yml exec -T minio mc ready local 2>/dev/null; then
+        echo "MinIO is ready"
+        break
+    fi
+    if [ $i -eq 30 ]; then
+        echo "MinIO failed to start"
+        docker compose -f docker-compose.test.yml logs minio
+        docker compose -f docker-compose.test.yml down
+        exit 1
+    fi
+    sleep 1
+done
+
+# Create test bucket using mc client inside the container
+echo "Creating test bucket..."
+docker compose -f docker-compose.test.yml exec -T minio mc alias set local http://localhost:9000 minioadmin minioadmin
+docker compose -f docker-compose.test.yml exec -T minio mc mb local/test-bucket --ignore-existing
+
+# Set up cleanup trap
+cleanup() {
+    echo "Cleaning up..."
+    docker compose -f docker-compose.test.yml down
+}
+trap cleanup EXIT
+
+# Export environment variables for the S3 integration tests
+export LITESTREAM_S3_ACCESS_KEY_ID=minioadmin
+export LITESTREAM_S3_SECRET_ACCESS_KEY=minioadmin
+export LITESTREAM_S3_BUCKET=test-bucket
+export LITESTREAM_S3_ENDPOINT=http://localhost:9000
+export LITESTREAM_S3_FORCE_PATH_STYLE=true
+export LITESTREAM_S3_REGION=us-east-1
+
+echo "Running S3 integration tests against MinIO..."
+go test -v ./replica_client_test.go -integration -replica-clients=s3 "$@"
+
+echo "Tests completed successfully!"
diff --git a/replica_url.go b/replica_url.go
index 87051f0..4644354 100644
--- a/replica_url.go
+++ b/replica_url.go
@@ -189,16 +189,94 @@ func BoolQueryValue(query url.Values, keys ...string) (value bool, ok bool) {
 
 // IsTigrisEndpoint returns true if the endpoint is the Tigris object storage service.
 func IsTigrisEndpoint(endpoint string) bool {
+	host := extractEndpointHost(endpoint)
+	return host == "fly.storage.tigris.dev" || host == "t3.storage.dev"
+}
+
+// IsDigitalOceanEndpoint returns true if the endpoint is Digital Ocean Spaces.
+func IsDigitalOceanEndpoint(endpoint string) bool {
+	host := extractEndpointHost(endpoint)
+	if host == "" {
+		return false
+	}
+	return strings.HasSuffix(host, ".digitaloceanspaces.com")
+}
+
+// IsBackblazeEndpoint returns true if the endpoint is Backblaze B2.
+func IsBackblazeEndpoint(endpoint string) bool {
+	host := extractEndpointHost(endpoint)
+	if host == "" {
+		return false
+	}
+	return strings.HasSuffix(host, ".backblazeb2.com")
+}
+
+// IsFilebaseEndpoint returns true if the endpoint is Filebase.
+func IsFilebaseEndpoint(endpoint string) bool {
+	host := extractEndpointHost(endpoint)
+	if host == "" {
+		return false
+	}
+	return host == "s3.filebase.com"
+}
+
+// IsScalewayEndpoint returns true if the endpoint is Scaleway Object Storage.
+func IsScalewayEndpoint(endpoint string) bool {
+	host := extractEndpointHost(endpoint)
+	if host == "" {
+		return false
+	}
+	return strings.HasSuffix(host, ".scw.cloud")
+}
+
+// IsCloudflareR2Endpoint returns true if the endpoint is Cloudflare R2.
+func IsCloudflareR2Endpoint(endpoint string) bool {
+	host := extractEndpointHost(endpoint)
+	if host == "" {
+		return false
+	}
+	return strings.HasSuffix(host, ".r2.cloudflarestorage.com")
+}
+
+// IsMinIOEndpoint returns true if the endpoint appears to be MinIO or similar
+// (a custom endpoint with a port number that is not a known cloud provider).
+func IsMinIOEndpoint(endpoint string) bool {
+	host := extractEndpointHost(endpoint)
+	if host == "" {
+		return false
+	}
+	// MinIO typically uses host:port format without .com domain
+	// Check for port number in the host
+	if !strings.Contains(host, ":") {
+		return false
+	}
+	// Exclude known cloud providers
+	if strings.Contains(host, ".amazonaws.com") ||
+		strings.Contains(host, ".digitaloceanspaces.com") ||
+		strings.Contains(host, ".backblazeb2.com") ||
+		strings.Contains(host, ".filebase.com") ||
+		strings.Contains(host, ".scw.cloud") ||
+		strings.Contains(host, ".r2.cloudflarestorage.com") ||
+		strings.Contains(host, "tigris.dev") ||
+		strings.Contains(host, "t3.storage.dev") {
+		return false
+	}
+	return true
+}
+
+// extractEndpointHost extracts the host from an endpoint URL or returns the
+// endpoint as-is if it's not a full URL.
+func extractEndpointHost(endpoint string) string {
 	endpoint = strings.TrimSpace(strings.ToLower(endpoint))
 	if endpoint == "" {
-		return false
+		return ""
 	}
 	if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") {
 		if u, err := url.Parse(endpoint); err == nil && u.Host != "" {
-			endpoint = u.Host
+			return u.Host
 		}
 	}
-	return endpoint == "fly.storage.tigris.dev"
+	return endpoint
 }
 
 // IsURL returns true if s appears to be a URL (has a scheme).
diff --git a/s3/replica_client.go b/s3/replica_client.go
index 0d0bb4e..61b89b2 100644
--- a/s3/replica_client.go
+++ b/s3/replica_client.go
@@ -80,6 +80,7 @@ type ReplicaClient struct {
 	SkipVerify        bool
 	SignPayload       bool
 	RequireContentMD5 bool
+	IsTigris          bool
 
 	// Upload configuration
 	PartSize    int64 // Part size for multipart uploads (default: 5MB)
@@ -154,8 +155,17 @@ func NewReplicaClientFromURL(scheme, host, urlPath string, query url.Values, use
 		return nil, fmt.Errorf("bucket required for s3 replica URL")
 	}
 
-	// Check for Tigris endpoint
+	// Detect S3-compatible provider endpoints for applying appropriate defaults.
 	isTigris := litestream.IsTigrisEndpoint(endpoint)
+	isDigitalOcean := litestream.IsDigitalOceanEndpoint(endpoint)
+	isBackblaze := litestream.IsBackblazeEndpoint(endpoint)
+	isFilebase := litestream.IsFilebaseEndpoint(endpoint)
+	isScaleway := litestream.IsScalewayEndpoint(endpoint)
+	isCloudflareR2 := litestream.IsCloudflareR2Endpoint(endpoint)
+	isMinIO := litestream.IsMinIOEndpoint(endpoint)
+
+	// Track if forcePathStyle was explicitly set via query parameter.
+	forcePathStyleSet := query.Get("forcePathStyle") != ""
 
 	// Read authentication from environment variables
 	if v := os.Getenv("AWS_ACCESS_KEY_ID"); v != "" {
@@ -169,16 +179,10 @@ func NewReplicaClientFromURL(scheme, host, urlPath string, query url.Values, use
 		client.SecretAccessKey = v
 	}
 
-	// Configure client
-	client.Bucket = bucket
-	client.Path = urlPath
-	client.Region = region
-	client.Endpoint = endpoint
-	client.ForcePathStyle = forcePathStyle
-	client.SkipVerify = skipVerify
-
-	// Apply Tigris defaults
+	// Apply provider-specific defaults for S3-compatible providers.
 	if isTigris {
+		// Tigris: requires signed payloads, no MD5
+		client.IsTigris = true
 		if !signPayloadSet {
 			signPayload, signPayloadSet = true, true
 		}
@@ -186,6 +190,26 @@ func NewReplicaClientFromURL(scheme, host, urlPath string, query url.Values, use
 			requireMD5, requireMD5Set = false, true
 		}
 	}
+	if isDigitalOcean || isBackblaze || isFilebase || isScaleway || isCloudflareR2 || isMinIO {
+		// All these providers require signed payloads (don't support UNSIGNED-PAYLOAD)
+		if !signPayloadSet {
+			signPayload, signPayloadSet = true, true
+		}
+	}
+	if !forcePathStyleSet {
+		// Filebase, Backblaze B2, and MinIO require path-style URLs
+		if isFilebase || isBackblaze || isMinIO {
+			forcePathStyle = true
+		}
+	}
+
+	// Configure client
+	client.Bucket = bucket
+	client.Path = urlPath
+	client.Region = region
+	client.Endpoint = endpoint
+	client.ForcePathStyle = forcePathStyle
+	client.SkipVerify = skipVerify
 
 	if signPayloadSet {
 		client.SignPayload = signPayload
@@ -539,6 +563,25 @@ func (c *ReplicaClient) middlewareOption() func(*middleware.Stack) error {
 			return err
 		}
 
+		if c.IsTigris {
+			if err := stack.Build.Add(
+				middleware.BuildMiddlewareFunc(
+					"LitestreamTigrisConsistent",
+					func(ctx context.Context, in middleware.BuildInput, next middleware.BuildHandler) (
+						out middleware.BuildOutput, metadata middleware.Metadata, err error,
+					) {
+						if req, ok := in.Request.(*smithyhttp.Request); ok {
+							req.Header.Set("X-Tigris-Consistent", "true")
+						}
+						return next.HandleBuild(ctx, in)
+					},
+				),
+				middleware.After,
+			); err != nil {
+				return err
+			}
+		}
+
 		// Many S3-compatible providers (e.g. Filebase) do not support SigV4
 		// payload hashing. Switching to unsigned payload matches the behavior
 		// of the AWS SDK v1 client used in Litestream v0.3.x and restores
@@ -552,12 +595,16 @@ func (c *ReplicaClient) middlewareOption() func(*middleware.Stack) error {
 			if err := v4.AddContentSHA256HeaderMiddleware(stack); err != nil {
 				return err
 			}
+		}
 
-			// Disable AWS SDK v2's trailing checksum middleware which uses
-			// aws-chunked encoding that is incompatible with UNSIGNED-PAYLOAD.
-			// AWS S3 rejects requests with: "aws-chunked encoding is not supported
-			// when x-amz-content-sha256 UNSIGNED-PAYLOAD is supplied."
-			// See: https://github.com/aws/aws-sdk-go-v2/discussions/2960
+		// Disable AWS SDK v2's trailing checksum middleware which uses
+		// aws-chunked encoding. This is required for:
+		// 1. UNSIGNED-PAYLOAD requests (aws-chunked + UNSIGNED-PAYLOAD is rejected by AWS)
+		// 2. S3-compatible providers (Filebase, MinIO, Backblaze B2, etc.) that don't
+		//    support aws-chunked encoding at all
+		// See: https://github.com/aws/aws-sdk-go-v2/discussions/2960
+		// See: https://github.com/benbjohnson/litestream/issues/895
+		if !c.SignPayload || c.Endpoint != "" {
 			stack.Finalize.Remove("addInputChecksumTrailer")
 		}
 



Test patch:
diff --git a/cmd/litestream/main.go b/cmd/litestream/main.go
index d97cd6e..fb1edb9 100644
--- a/cmd/litestream/main.go
+++ b/cmd/litestream/main.go
@@ -1261,10 +1261,38 @@ func NewS3ReplicaClientFromConfig(c *ReplicaConfig, _ *litestream.Replica) (_ *s
 		return nil, fmt.Errorf("bucket required for s3 replica")
 	}
 
+	// Detect S3-compatible provider endpoints for applying appropriate defaults.
+	// These providers require specific settings to work correctly with AWS SDK v2.
 	isTigris := litestream.IsTigrisEndpoint(endpoint)
 	if !isTigris && !endpointWasSet && litestream.IsTigrisEndpoint(c.Endpoint) {
 		isTigris = true
 	}
+	isDigitalOcean := litestream.IsDigitalOceanEndpoint(endpoint)
+	isBackblaze := litestream.IsBackblazeEndpoint(endpoint)
+	isFilebase := litestream.IsFilebaseEndpoint(endpoint)
+	isScaleway := litestream.IsScalewayEndpoint(endpoint)
+	isMinIO := litestream.IsMinIOEndpoint(endpoint)
+
+	// Track if forcePathStyle was explicitly set by user (config or URL query param).
+	forcePathStyleSet := c.ForcePathStyle != nil
+
+	// Apply provider-specific defaults for S3-compatible providers.
+	// These settings ensure compatibility with each provider's S3 implementation.
+	if isTigris {
+		// Tigris: requires signed payloads, no MD5
+		signSetting.ApplyDefault(true)
+		requireSetting.ApplyDefault(false)
+	}
+	if isDigitalOcean || isBackblaze || isFilebase || isScaleway || isMinIO {
+		// All these providers require signed payloads (don't support UNSIGNED-PAYLOAD)
+		signSetting.ApplyDefault(true)
+	}
+	if !forcePathStyleSet {
+		// Filebase, Backblaze B2, and MinIO require path-style URLs
+		if isFilebase || isBackblaze || isMinIO {
+			forcePathStyle = true
+		}
+	}
 
 	// Build replica.
 	client := s3.NewReplicaClient()
@@ -1276,13 +1304,10 @@ func NewS3ReplicaClientFromConfig(c *ReplicaConfig, _ *litestream.Replica) (_ *s
 	client.Endpoint = endpoint
 	client.ForcePathStyle = forcePathStyle
 	client.SkipVerify = skipVerify
-	if isTigris {
-		signSetting.ApplyDefault(true)
-		requireSetting.ApplyDefault(false)
-	}
 
 	client.SignPayload = signSetting.value
 	client.RequireContentMD5 = requireSetting.value
+	client.IsTigris = isTigris
 
 	// Apply upload configuration if specified.
 	if c.PartSize != nil {
diff --git a/db_internal_test.go b/db_internal_test.go
index ddb9ddf..ffa1cbc 100644
--- a/db_internal_test.go
+++ b/db_internal_test.go
@@ -3,6 +3,7 @@ package litestream
 import (
 	"context"
 	"database/sql"
+	"encoding/binary"
 	"errors"
 	"fmt"
 	"io"
@@ -387,3 +388,243 @@ func TestDB_Sync_ErrorMetrics(t *testing.T) {
 		t.Fatalf("litestream_sync_error_count=%v, want > %v", syncErrorValue, baselineErrors)
 	}
 }
+
+// TestDB_Verify_WALOffsetAtHeader tests that verify() handles the edge case where
+// an LTX file has WALOffset=WALHeaderSize and WALSize=0, which means we're at the
+// beginning of the WAL with no frames written yet.
+// Regression test for issue #900: prev WAL offset is less than the header size: -4088
+func TestDB_Verify_WALOffsetAtHeader(t *testing.T) {
+	dir := t.TempDir()
+	dbPath := filepath.Join(dir, "db")
+
+	db := NewDB(dbPath)
+	db.MonitorInterval = 0
+	db.Replica = NewReplica(db)
+	db.Replica.Client = &testReplicaClient{dir: t.TempDir()}
+	db.Replica.MonitorEnabled = false
+	if err := db.Open(); err != nil {
+		t.Fatal(err)
+	}
+	defer func() {
+		if err := db.Close(context.Background()); err != nil {
+			t.Fatal(err)
+		}
+	}()
+
+	sqldb, err := sql.Open("sqlite", dbPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer sqldb.Close()
+
+	if _, err := sqldb.Exec(`PRAGMA journal_mode = wal;`); err != nil {
+		t.Fatal(err)
+	}
+
+	if _, err := sqldb.Exec(`CREATE TABLE t (id INT)`); err != nil {
+		t.Fatal(err)
+	}
+
+	// Perform initial sync to set up page size and initial state
+	if err := db.Sync(context.Background()); err != nil {
+		t.Fatal(err)
+	}
+
+	// Read the WAL header to get current salt values
+	walHdr, err := readWALHeader(db.WALPath())
+	if err != nil {
+		t.Fatal(err)
+	}
+	salt1 := binary.BigEndian.Uint32(walHdr[16:])
+	salt2 := binary.BigEndian.Uint32(walHdr[20:])
+
+	// Create an LTX file with WALOffset=WALHeaderSize (32) and WALSize=0
+	// This simulates the condition in issue #900
+	ltxDir := db.LTXLevelDir(0)
+	if err := os.MkdirAll(ltxDir, 0o755); err != nil {
+		t.Fatal(err)
+	}
+
+	// Get current position to determine next TXID
+	pos, err := db.Pos()
+	if err != nil {
+		t.Fatal(err)
+	}
+	nextTXID := pos.TXID + 1
+
+	ltxPath := db.LTXPath(0, nextTXID, nextTXID)
+	f, err := os.Create(ltxPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	enc, err := ltx.NewEncoder(f)
+	if err != nil {
+		f.Close()
+		t.Fatal(err)
+	}
+
+	// Create header with WALOffset=32 (WALHeaderSize) and WALSize=0
+	hdr := ltx.Header{
+		Version:   ltx.Version,
+		Flags:     ltx.HeaderFlagNoChecksum,
+		PageSize:  uint32(db.pageSize),
+		Commit:    2,
+		MinTXID:   nextTXID,
+		MaxTXID:   nextTXID,
+		Timestamp: 1000000,
+		WALOffset: WALHeaderSize, // 32 - at start of WAL
+		WALSize:   0,             // No WAL data - this triggers the bug
+		WALSalt1:  salt1,
+		WALSalt2:  salt2,
+	}
+
+	if err := enc.EncodeHeader(hdr); err != nil {
+		f.Close()
+		t.Fatal(err)
+	}
+	if err := enc.Close(); err != nil {
+		f.Close()
+		t.Fatal(err)
+	}
+	if err := f.Close(); err != nil {
+		t.Fatal(err)
+	}
+
+	// Now call verify - before the fix, this would fail with:
+	// "prev WAL offset is less than the header size: -4088"
+	info, err := db.verify(context.Background())
+	if err != nil {
+		t.Fatalf("verify() returned error: %v", err)
+	}
+
+	// Verify the returned info is sensible
+	if info.offset != WALHeaderSize {
+		t.Errorf("expected offset=%d, got %d", WALHeaderSize, info.offset)
+	}
+	// Salt matches, so snapshotting should be false
+	if info.snapshotting {
+		t.Errorf("expected snapshotting=false when salt matches, got true")
+	}
+}
+
+// TestDB_Verify_WALOffsetAtHeader_SaltMismatch tests that verify() correctly
+// triggers a snapshot when WALOffset=WALHeaderSize, WALSize=0, and the salt
+// values don't match the current WAL header.
+// Companion test to TestDB_Verify_WALOffsetAtHeader for full branch coverage.
+func TestDB_Verify_WALOffsetAtHeader_SaltMismatch(t *testing.T) {
+	dir := t.TempDir()
+	dbPath := filepath.Join(dir, "db")
+
+	db := NewDB(dbPath)
+	db.MonitorInterval = 0
+	db.Replica = NewReplica(db)
+	db.Replica.Client = &testReplicaClient{dir: t.TempDir()}
+	db.Replica.MonitorEnabled = false
+	if err := db.Open(); err != nil {
+		t.Fatal(err)
+	}
+	defer func() {
+		if err := db.Close(context.Background()); err != nil {
+			t.Fatal(err)
+		}
+	}()
+
+	sqldb, err := sql.Open("sqlite", dbPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer sqldb.Close()
+
+	if _, err := sqldb.Exec(`PRAGMA journal_mode = wal;`); err != nil {
+		t.Fatal(err)
+	}
+
+	if _, err := sqldb.Exec(`CREATE TABLE t (id INT)`); err != nil {
+		t.Fatal(err)
+	}
+
+	// Perform initial sync to set up page size and initial state
+	if err := db.Sync(context.Background()); err != nil {
+		t.Fatal(err)
+	}
+
+	// Read the WAL header to get current salt values
+	walHdr, err := readWALHeader(db.WALPath())
+	if err != nil {
+		t.Fatal(err)
+	}
+	salt1 := binary.BigEndian.Uint32(walHdr[16:])
+	salt2 := binary.BigEndian.Uint32(walHdr[20:])
+
+	// Create an LTX file with WALOffset=WALHeaderSize (32) and WALSize=0
+	// but with DIFFERENT salt values to simulate a salt reset
+	ltxDir := db.LTXLevelDir(0)
+	if err := os.MkdirAll(ltxDir, 0o755); err != nil {
+		t.Fatal(err)
+	}
+
+	// Get current position to determine next TXID
+	pos, err := db.Pos()
+	if err != nil {
+		t.Fatal(err)
+	}
+	nextTXID := pos.TXID + 1
+
+	ltxPath := db.LTXPath(0, nextTXID, nextTXID)
+	f, err := os.Create(ltxPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	enc, err := ltx.NewEncoder(f)
+	if err != nil {
+		f.Close()
+		t.Fatal(err)
+	}
+
+	// Create header with WALOffset=32 (WALHeaderSize) and WALSize=0
+	// Use different salt values to trigger salt mismatch branch
+	hdr := ltx.Header{
+		Version:   ltx.Version,
+		Flags:     ltx.HeaderFlagNoChecksum,
+		PageSize:  uint32(db.pageSize),
+		Commit:    2,
+		MinTXID:   nextTXID,
+		MaxTXID:   nextTXID,
+		Timestamp: 1000000,
+		WALOffset: WALHeaderSize, // 32 - at start of WAL
+		WALSize:   0,             // No WAL data
+		WALSalt1:  salt1 + 1,     // Different salt to trigger mismatch
+		WALSalt2:  salt2 + 1,     // Different salt to trigger mismatch
+	}
+
+	if err := enc.EncodeHeader(hdr); err != nil {
+		f.Close()
+		t.Fatal(err)
+	}
+	if err := enc.Close(); err != nil {
+		f.Close()
+		t.Fatal(err)
+	}
+	if err := f.Close(); err != nil {
+		t.Fatal(err)
+	}
+
+	// Call verify - should succeed but indicate snapshotting due to salt mismatch
+	info, err := db.verify(context.Background())
+	if err != nil {
+		t.Fatalf("verify() returned error: %v", err)
+	}
+
+	// Verify the returned info indicates snapshotting due to salt reset
+	if info.offset != WALHeaderSize {
+		t.Errorf("expected offset=%d, got %d", WALHeaderSize, info.offset)
+	}
+	if !info.snapshotting {
+		t.Errorf("expected snapshotting=true when salt mismatches, got false")
+	}
+	if info.reason != "wal header salt reset, snapshotting" {
+		t.Errorf("expected reason='wal header salt reset, snapshotting', got %q", info.reason)
+	}
+}
diff --git a/db_test.go b/db_test.go
index 33bf52c..28f0689 100644
--- a/db_test.go
+++ b/db_test.go
@@ -1071,3 +1071,141 @@ func TestDB_SyncAfterVacuum(t *testing.T) {
 	}
 	t.Logf("Final position: TXID=%d", pos.TXID)
 }
+
+// TestDB_NoLTXFilesOnIdleSync verifies that syncing an idle database does not
+// create new LTX files when no external changes have been made. This tests the
+// fix for issue #896 where time-based checkpoints were creating LTX files even
+// when no actual database changes occurred.
+func TestDB_NoLTXFilesOnIdleSync(t *testing.T) {
+	db, sqldb := testingutil.MustOpenDBs(t)
+	defer testingutil.MustCloseDBs(t, db, sqldb)
+
+	// Set CheckpointInterval to trigger time-based checkpoints
+	db.CheckpointInterval = time.Millisecond
+
+	// Create a table and insert some data to ensure we have WAL activity
+	if _, err := sqldb.ExecContext(t.Context(), `CREATE TABLE t (id INTEGER PRIMARY KEY, data TEXT)`); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := sqldb.ExecContext(t.Context(), `INSERT INTO t (data) VALUES ('test')`); err != nil {
+		t.Fatal(err)
+	}
+
+	// Initial sync to create first LTX file(s)
+	if err := db.Sync(t.Context()); err != nil {
+		t.Fatal(err)
+	}
+
+	// Wait for checkpoint interval to pass
+	time.Sleep(10 * time.Millisecond)
+
+	// Sync again to trigger checkpoint (this will write to _litestream_seq)
+	if err := db.Sync(t.Context()); err != nil {
+		t.Fatal(err)
+	}
+
+	// Record the current TXID after checkpoint
+	posAfterCheckpoint, err := db.Pos()
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Logf("TXID after checkpoint: %d", posAfterCheckpoint.TXID)
+
+	// Wait for checkpoint interval to pass again
+	time.Sleep(10 * time.Millisecond)
+
+	// Now sync multiple times without any external database changes
+	// This is the key part of the test - with the bug, each sync would create
+	// a new LTX file because the time-based checkpoint would trigger
+	for i := 0; i < 3; i++ {
+		if err := db.Sync(t.Context()); err != nil {
+			t.Fatalf("sync %d failed: %v", i, err)
+		}
+		time.Sleep(10 * time.Millisecond)
+	}
+
+	// Check final position - it should NOT have advanced significantly
+	// With the bug, TXID would increase by 3 (one for each sync)
+	posAfterIdle, err := db.Pos()
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Logf("TXID after idle syncs: %d", posAfterIdle.TXID)
+
+	// The TXID should not have advanced more than 1 from the checkpoint
+	// (accounting for the checkpoint's own _litestream_seq write)
+	if posAfterIdle.TXID > posAfterCheckpoint.TXID+1 {
+		t.Fatalf("expected TXID to stay at or below %d, got %d (bug: LTX files created without changes)",
+			posAfterCheckpoint.TXID+1, posAfterIdle.TXID)
+	}
+}
+
+// TestDB_DelayedCheckpointAfterWrite verifies that writes that happen before
+// the checkpoint interval elapses will still trigger a checkpoint later when
+// the interval does elapse. This ensures the syncedSinceCheckpoint flag
+// persists across sync calls. See issue #896.
+func TestDB_DelayedCheckpointAfterWrite(t *testing.T) {
+	db, sqldb := testingutil.MustOpenDBs(t)
+	defer testingutil.MustCloseDBs(t, db, sqldb)
+
+	// Use a longer checkpoint interval so we can control when it triggers
+	db.CheckpointInterval = 100 * time.Millisecond
+
+	// Create table and initial sync
+	if _, err := sqldb.ExecContext(t.Context(), `CREATE TABLE t (id INTEGER PRIMARY KEY, data TEXT)`); err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Sync(t.Context()); err != nil {
+		t.Fatal(err)
+	}
+
+	// Wait for interval to pass and sync to trigger initial checkpoint
+	time.Sleep(150 * time.Millisecond)
+	if err := db.Sync(t.Context()); err != nil {
+		t.Fatal(err)
+	}
+
+	// Record TXID after first checkpoint
+	posAfterFirstCheckpoint, err := db.Pos()
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Logf("TXID after first checkpoint: %d", posAfterFirstCheckpoint.TXID)
+
+	// Insert data immediately (before interval elapses)
+	if _, err := sqldb.ExecContext(t.Context(), `INSERT INTO t (data) VALUES ('delayed checkpoint test')`); err != nil {
+		t.Fatal(err)
+	}
+
+	// Sync immediately - this should NOT trigger a checkpoint (interval hasn't elapsed)
+	// but should set syncedSinceCheckpoint = true
+	if err := db.Sync(t.Context()); err != nil {
+		t.Fatal(err)
+	}
+
+	posAfterInsert, err := db.Pos()
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Logf("TXID after insert+sync: %d", posAfterInsert.TXID)
+
+	// Now wait for the interval to pass and sync again (no new data)
+	time.Sleep(150 * time.Millisecond)
+	if err := db.Sync(t.Context()); err != nil {
+		t.Fatal(err)
+	}
+
+	// A checkpoint should have been triggered because syncedSinceCheckpoint was true
+	// The TXID should have advanced due to the checkpoint
+	posAfterDelayedCheckpoint, err := db.Pos()
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Logf("TXID after delayed checkpoint: %d", posAfterDelayedCheckpoint.TXID)
+
+	// The TXID should have advanced from the insert position, indicating the checkpoint ran
+	if posAfterDelayedCheckpoint.TXID <= posAfterInsert.TXID {
+		t.Fatalf("expected TXID to advance after delayed checkpoint (syncedSinceCheckpoint should persist), got insert=%d delayed=%d",
+			posAfterInsert.TXID, posAfterDelayedCheckpoint.TXID)
+	}
+}
diff --git a/replica_url_test.go b/replica_url_test.go
index b77125a..bc3680b 100644
--- a/replica_url_test.go
+++ b/replica_url_test.go
@@ -562,6 +562,10 @@ func TestIsTigrisEndpoint(t *testing.T) {
 		{"FLY.STORAGE.TIGRIS.DEV", true},
 		{"https://fly.storage.tigris.dev", true},
 		{"http://fly.storage.tigris.dev", true},
+		{"t3.storage.dev", true},
+		{"T3.STORAGE.DEV", true},
+		{"https://t3.storage.dev", true},
+		{"http://t3.storage.dev", true},
 		{"s3.amazonaws.com", false},
 		{"localhost:9000", false},
 		{"", false},
@@ -630,3 +634,137 @@ func TestCleanReplicaURLPath(t *testing.T) {
 		})
 	}
 }
+
+func TestIsDigitalOceanEndpoint(t *testing.T) {
+	tests := []struct {
+		endpoint string
+		expected bool
+	}{
+		{"https://sfo3.digitaloceanspaces.com", true},
+		{"https://nyc3.digitaloceanspaces.com", true},
+		{"sfo3.digitaloceanspaces.com", true},
+		{"https://s3.amazonaws.com", false},
+		{"https://s3.filebase.com", false},
+		{"", false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.endpoint, func(t *testing.T) {
+			got := litestream.IsDigitalOceanEndpoint(tt.endpoint)
+			if got != tt.expected {
+				t.Errorf("IsDigitalOceanEndpoint(%q) = %v, want %v", tt.endpoint, got, tt.expected)
+			}
+		})
+	}
+}
+
+func TestIsBackblazeEndpoint(t *testing.T) {
+	tests := []struct {
+		endpoint string
+		expected bool
+	}{
+		{"https://s3.us-west-002.backblazeb2.com", true},
+		{"https://s3.eu-central-003.backblazeb2.com", true},
+		{"s3.us-west-002.backblazeb2.com", true},
+		{"https://s3.amazonaws.com", false},
+		{"https://s3.filebase.com", false},
+		{"", false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.endpoint, func(t *testing.T) {
+			got := litestream.IsBackblazeEndpoint(tt.endpoint)
+			if got != tt.expected {
+				t.Errorf("IsBackblazeEndpoint(%q) = %v, want %v", tt.endpoint, got, tt.expected)
+			}
+		})
+	}
+}
+
+func TestIsFilebaseEndpoint(t *testing.T) {
+	tests := []struct {
+		endpoint string
+		expected bool
+	}{
+		{"https://s3.filebase.com", true},
+		{"http://s3.filebase.com", true},
+		{"s3.filebase.com", true},
+		{"https://s3.amazonaws.com", false},
+		{"https://sfo3.digitaloceanspaces.com", false},
+		{"", false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.endpoint, func(t *testing.T) {
+			got := litestream.IsFilebaseEndpoint(tt.endpoint)
+			if got != tt.expected {
+				t.Errorf("IsFilebaseEndpoint(%q) = %v, want %v", tt.endpoint, got, tt.expected)
+			}
+		})
+	}
+}
+
+func TestIsScalewayEndpoint(t *testing.T) {
+	tests := []struct {
+		endpoint string
+		expected bool
+	}{
+		{"https://s3.fr-par.scw.cloud", true},
+		{"https://s3.nl-ams.scw.cloud", true},
+		{"s3.fr-par.scw.cloud", true},
+		{"https://s3.amazonaws.com", false},
+		{"https://s3.filebase.com", false},
+		{"", false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.endpoint, func(t *testing.T) {
+			got := litestream.IsScalewayEndpoint(tt.endpoint)
+			if got != tt.expected {
+				t.Errorf("IsScalewayEndpoint(%q) = %v, want %v", tt.endpoint, got, tt.expected)
+			}
+		})
+	}
+}
+
+func TestIsCloudflareR2Endpoint(t *testing.T) {
+	tests := []struct {
+		endpoint string
+		expected bool
+	}{
+		{"https://abcdef123456.r2.cloudflarestorage.com", true},
+		{"https://account-id.r2.cloudflarestorage.com", true},
+		{"abcdef123456.r2.cloudflarestorage.com", true},
+		{"https://s3.amazonaws.com", false},
+		{"https://s3.filebase.com", false},
+		{"", false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.endpoint, func(t *testing.T) {
+			got := litestream.IsCloudflareR2Endpoint(tt.endpoint)
+			if got != tt.expected {
+				t.Errorf("IsCloudflareR2Endpoint(%q) = %v, want %v", tt.endpoint, got, tt.expected)
+			}
+		})
+	}
+}
+
+func TestIsMinIOEndpoint(t *testing.T) {
+	tests := []struct {
+		endpoint string
+		expected bool
+	}{
+		{"http://localhost:9000", true},
+		{"http://192.168.1.100:9000", true},
+		{"minio.local:9000", true},
+		{"https://s3.amazonaws.com", false},
+		{"https://s3.filebase.com", false},
+		{"https://sfo3.digitaloceanspaces.com", false},
+		{"s3.filebase.com", false}, // No port, not MinIO
+		{"", false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.endpoint, func(t *testing.T) {
+			got := litestream.IsMinIOEndpoint(tt.endpoint)
+			if got != tt.expected {
+				t.Errorf("IsMinIOEndpoint(%q) = %v, want %v", tt.endpoint, got, tt.expected)
+			}
+		})
+	}
+}
diff --git a/s3/replica_client_test.go b/s3/replica_client_test.go
index 7239f72..baac7af 100644
--- a/s3/replica_client_test.go
+++ b/s3/replica_client_test.go
@@ -219,6 +219,82 @@ func TestReplicaClient_UnsignedPayload_NoChunkedEncoding(t *testing.T) {
 	}
 }
 
+// TestReplicaClient_SignedPayload_CustomEndpoint_NoChunkedEncoding verifies that
+// aws-chunked encoding is disabled for custom endpoints even when SignPayload=true.
+// This is necessary for S3-compatible providers (Filebase, MinIO, Backblaze B2, etc.)
+// that don't support aws-chunked encoding at all. See issue #895.
+func TestReplicaClient_SignedPayload_CustomEndpoint_NoChunkedEncoding(t *testing.T) {
+	data := mustLTX(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 // Custom endpoint (non-AWS)
+	client.ForcePathStyle = true
+	client.AccessKeyID = "test-access-key"
+	client.SecretAccessKey = "test-secret-key"
+	client.SignPayload = true // Signed payload, but still using custom endpoint
+
+	ctx := context.Background()
+	if err := client.Init(ctx); err != nil {
+		t.Fatalf("Init() error: %v", err)
+	}
+
+	if _, err := client.WriteLTXFile(ctx, 0, 2, 2, bytes.NewReader(data)); err != nil {
+		t.Fatalf("WriteLTXFile() error: %v", err)
+	}
+
+	select {
+	case hdr := <-headers:
+		// With SignPayload=true, we expect an actual SHA256 hash (not UNSIGNED-PAYLOAD)
+		sha256Header := hdr.Get("x-amz-content-sha256")
+		if sha256Header == "" {
+			t.Error("x-amz-content-sha256 header should be set")
+		}
+		if sha256Header == "UNSIGNED-PAYLOAD" {
+			t.Error("x-amz-content-sha256 should be actual hash, not UNSIGNED-PAYLOAD, when SignPayload=true")
+		}
+
+		// But aws-chunked encoding should still be disabled for custom endpoints
+		contentEnc := hdr.Get("Content-Encoding")
+		if strings.Contains(contentEnc, "aws-chunked") {
+			t.Errorf("Content-Encoding contains aws-chunked: %q; aws-chunked is not supported by S3-compatible providers", contentEnc)
+		}
+
+		transferEnc := hdr.Get("Transfer-Encoding")
+		if strings.Contains(transferEnc, "aws-chunked") {
+			t.Errorf("Transfer-Encoding contains aws-chunked: %q; aws-chunked is not supported by S3-compatible providers", transferEnc)
+		}
+
+		decoded := hdr.Get("X-Amz-Decoded-Content-Length")
+		if decoded != "" {
+			t.Errorf("X-Amz-Decoded-Content-Length = %q; this header indicates aws-chunked encoding which is not supported by S3-compatible providers", decoded)
+		}
+	case <-time.After(time.Second):
+		t.Fatal("timeout waiting for PUT request")
+	}
+}
+
 func mustLTX(t *testing.T) []byte {
 	t.Helper()
 
@@ -930,3 +1006,68 @@ func TestParseHost(t *testing.T) {
 		})
 	}
 }
+
+func TestReplicaClient_TigrisConsistentHeader(t *testing.T) {
+	data := mustLTX(t)
+
+	tests := []struct {
+		name       string
+		isTigris   bool
+		wantHeader string
+	}{
+		{name: "TigrisEnabled", isTigris: true, wantHeader: "true"},
+		{name: "TigrisDisabled", isTigris: false, wantHeader: ""},
+	}
+
+	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.IsTigris = tt.isTigris
+
+			ctx := context.Background()
+			if err := client.Init(ctx); err != nil {
+				t.Fatalf("Init() error: %v", err)
+			}
+
+			if _, err := client.WriteLTXFile(ctx, 0, 2, 2, bytes.NewReader(data)); err != nil {
+				t.Fatalf("WriteLTXFile() error: %v", err)
+			}
+
+			select {
+			case hdr := <-headers:
+				got := hdr.Get("X-Tigris-Consistent")
+				if got != tt.wantHeader {
+					t.Fatalf("X-Tigris-Consistent header = %q, want %q", got, tt.wantHeader)
+				}
+			case <-time.After(time.Second):
+				t.Fatal("timeout waiting for PUT request")
+			}
+		})
+	}
+}