Repository: benbjohnson/litestream

Issue:
CheckpointInterval is creating checkpoints when it should't
## Bug Description

Default CheckpointInterval always creates new LTX files even with no changes to WAL after v0.5.2.
Don't know if this is really a bug or a feature on recent changes.

## Environment

**Litestream version:**
<!--- Run `litestream version` and paste output here -->
```text
v0.5.3
```

**Operating system & version:**
- Docker (official litestream image)
-  Also cloned locally and run with `go run .` to check on Debian machine.

**Installation method:**
Docker

**Storage backend:**
S3 (minio)

## Steps to Reproduce

1. leave **checkpoint-interval** undefined on config file (defaults to 1 minute)
2. Start litestream replication.
3. Make no writes to database from any other application.

**Expected behavior:**

LTX files should only be created if there are changes to database (as of my understanding).
Checkpoint interval should also check if WAL size changed (based on the following code), but newWALSize is always greater than calculated one.

`db.go:702`
```go
// Only checkpoint if enough time has passed and WAL has data
if time.Since(fi.ModTime()) > db.CheckpointInterval && newWALSize > calcWALSize(uint32(db.pageSize), 1) {
```


**Actual behavior:**
A new LTX files is created every minute or value defined by user to CheckpointInterval.
This is causing some users to get the replica to be flooded by files that only contains the increment to the **_litestream_seq** table.
## Configuration
<details><summary>litestream.yml</summary>

```yaml
dbs:
  - path: your_db.db
    # checkpoint-interval: 1h
    replicas:
      - type: s3
        bucket: db-backups
        endpoint: http://127.0.0.1:9000
        access-key-id: minioadmin
        secret-access-key: minioadmin
        path: your-db
        region: us-east-1
        force-path-style: true
```

</details>

## Logs
<details><summary>Log output</summary>

```text

litestream.local-1  | time=2025-12-14T11:58:50.010Z level=INFO msg="starting compaction monitor" level=1 interval=30s
litestream.local-1  | time=2025-12-14T11:58:50.010Z level=INFO msg="starting compaction monitor" level=3 interval=1h0m0s
litestream.local-1  | time=2025-12-14T11:58:50.010Z level=INFO msg="starting compaction monitor" level=9 interval=24h0m0s
litestream.local-1  | time=2025-12-14T11:58:50.010Z level=INFO msg="starting compaction monitor" level=2 interval=5m0s
litestream.local-1  | time=2025-12-14T11:59:00.093Z level=INFO msg="compaction complete" level=1 txid.min=0000000000000295 txid.max=000000000000029c size=2304
litestream.local-1  | time=2025-12-14T12:00:00.053Z level=INFO msg="compaction complete" level=3 txid.min=0000000000000278 txid.max=0000000000000288 size=2304
litestream.local-1  | time=2025-12-14T12:00:00.062Z level=INFO msg="compaction complete" level=2 txid.min=0000000000000289 txid.max=000000000000029c size=2304
litestream.local-1  | time=2025-12-14T12:00:00.084Z level=INFO msg="compaction complete" level=1 txid.min=000000000000029d txid.max=000000000000029d size=2304
litestream.local-1  | time=2025-12-14T12:01:00.062Z level=INFO msg="compaction complete" level=1 txid.min=000000000000029e txid.max=000000000000029e size=2304
litestream.local-1  | time=2025-12-14T12:02:00.075Z level=INFO msg="compaction complete" level=1 txid.min=000000000000029f txid.max=000000000000029f size=2304
litestream.local-1  | time=2025-12-14T12:03:00.053Z level=INFO msg="compaction complete" level=1 txid.min=00000000000002a0 txid.max=00000000000002a0 size=2304

```

</details>

Discussion:
@chisenberg: Thanks for the fix @corylanou! Already using your branch and it seems to be working fine.

Solution patch:
diff --git a/db.go b/db.go
index b3c7824..1b2f449 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 {
@@ -1018,11 +1038,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 +1072,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 +1095,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 +1125,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 +1154,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 +1169,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 +1202,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 +1218,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 +1320,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 +1336,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 +1356,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 +1364,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_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")
+			}
+		})
+	}
+}