Repository: benbjohnson/litestream

Issue:
Digital Ocean Spaces api error SignatureDoesNotMatch
```yaml
access-key-id: redacted
secret-access-key: redacted

dbs:
  - path: /gotosocial/sqlite.db
    replica:
      url: s3://redacted.sfo3.digitaloceanspaces.com/db
```

## Bug Description

Connects fine to local sqlite db but write to Digital Ocean Spaces fails with Signature issues.

```syslog
time=2025-12-13T01:38:00.891Z level=INFO msg=litestream version=v0.5.3 level=""
time=2025-12-13T01:38:00.892Z level=INFO msg="initialized db" path=/gotosocial/sqlite.db
time=2025-12-13T01:38:00.893Z level=INFO msg="replicating to" type=s3 sync-interval=1s bucket=dustyninja-backup path=db region=sfo3 endpoint=https://sfo3.digitaloceanspaces.com
time=2025-12-13T01:38:00.893Z level=INFO msg="starting L0 retention monitor" interval=15s retention=5m0s
time=2025-12-13T01:38:00.897Z level=INFO msg="starting compaction monitor" level=1 interval=30s
time=2025-12-13T01:38:00.897Z level=INFO msg="starting compaction monitor" level=2 interval=5m0s
time=2025-12-13T01:38:00.897Z level=INFO msg="starting compaction monitor" level=3 interval=1h0m0s
time=2025-12-13T01:38:00.897Z level=INFO msg="starting compaction monitor" level=9 interval=24h0m0s
time=2025-12-13T01:38:30.887Z level=INFO msg="signal received, litestream shutting down"
time=2025-12-13T01:38:30.967Z level=ERROR msg="monitor error" db=sqlite.db replica=s3 error="no position, waiting for data"
time=2025-12-13T01:41:56.191Z level=INFO msg=litestream version=v0.5.3 level=debug
time=2025-12-13T01:41:56.195Z level=INFO msg="initialized db" path=/gotosocial/sqlite.db
time=2025-12-13T01:41:56.195Z level=INFO msg="replicating to" type=s3 sync-interval=1s bucket=dustyninja-backup path=db region=sfo3 endpoint=https://sfo3.digitaloceanspaces.com
time=2025-12-13T01:41:56.196Z level=INFO msg="starting L0 retention monitor" interval=15s retention=5m0s
time=2025-12-13T01:41:56.196Z level=INFO msg="starting compaction monitor" level=1 interval=30s
time=2025-12-13T01:41:56.196Z level=DEBUG msg="db not ready, skipping" level=1 path=/gotosocial/sqlite.db
time=2025-12-13T01:41:56.196Z level=INFO msg="starting compaction monitor" level=2 interval=5m0s
time=2025-12-13T01:41:56.196Z level=DEBUG msg="db not ready, skipping" level=2 path=/gotosocial/sqlite.db
time=2025-12-13T01:41:56.196Z level=INFO msg="starting compaction monitor" level=3 interval=1h0m0s
time=2025-12-13T01:41:56.197Z level=DEBUG msg="db not ready, skipping" level=3 path=/gotosocial/sqlite.db
time=2025-12-13T01:41:56.197Z level=INFO msg="starting compaction monitor" level=9 interval=24h0m0s
time=2025-12-13T01:41:56.197Z level=DEBUG msg="db not ready, skipping" level=9 path=/gotosocial/sqlite.db
time=2025-12-13T01:41:57.334Z level=DEBUG msg=sync db=sqlite.db txid=0000000000000001 offset=32 snap=true
time=2025-12-13T01:42:11.207Z level=DEBUG msg="enforcing l0 retention" db=sqlite.db retention=5m0s
time=2025-12-13T01:42:24.961Z level=DEBUG msg="db sync" db=sqlite.db status=ok
time=2025-12-13T01:42:26.199Z level=DEBUG msg="enforcing l0 retention" db=sqlite.db retention=5m0s
time=2025-12-13T01:42:39.233Z level=DEBUG msg=verify saltMatch=true prevWALOffset=1277232
time=2025-12-13T01:42:41.201Z level=DEBUG msg="enforcing l0 retention" db=sqlite.db retention=5m0s
time=2025-12-13T01:42:52.545Z level=DEBUG msg=verify.2 lastPageMatch=true
time=2025-12-13T01:42:56.198Z level=DEBUG msg="enforcing l0 retention" db=sqlite.db retention=5m0s
time=2025-12-13T01:43:06.235Z level=DEBUG msg=sync db=sqlite.db txid=0000000000000002 offset=1281352 chkpt=true
time=2025-12-13T01:43:06.275Z level=DEBUG msg="db sync" db=sqlite.db status=ok
time=2025-12-13T01:43:06.314Z level=DEBUG msg=checkpoint db=sqlite.db mode=PASSIVE result=0,1695,1695
time=2025-12-13T01:43:06.352Z level=DEBUG msg=verify saltMatch=false prevWALOffset=6979312
time=2025-12-13T01:43:06.364Z level=DEBUG msg=verify.2 lastPageMatch=true
time=2025-12-13T01:43:06.375Z level=DEBUG msg=sync db=sqlite.db txid=0000000000000003 offset=32 chkpt=true
time=2025-12-13T01:43:06.378Z level=DEBUG msg="db sync" db=sqlite.db status=ok
time=2025-12-13T01:43:06.380Z level=DEBUG msg=verify saltMatch=true prevWALOffset=32
time=2025-12-13T01:43:06.381Z level=DEBUG msg=sync db=sqlite.db txid=0000000000000004 offset=4152
time=2025-12-13T01:43:06.381Z level=DEBUG msg=verify saltMatch=true prevWALOffset=32
time=2025-12-13T01:43:06.382Z level=DEBUG msg=sync db=sqlite.db txid=0000000000000004 offset=4152 chkpt=true
time=2025-12-13T01:43:06.384Z level=DEBUG msg=checkpoint db=sqlite.db mode=PASSIVE result=0,1,1
time=2025-12-13T01:43:06.386Z level=DEBUG msg=verify saltMatch=false prevWALOffset=32
time=2025-12-13T01:43:06.387Z level=DEBUG msg=sync db=sqlite.db txid=0000000000000004 offset=4152 chkpt=true snap=true reason="wal header salt reset, snapshotting"
time=2025-12-13T01:43:06.422Z level=DEBUG msg="replica sync" db=sqlite.db replica=s3 txid=0000000000000003
time=2025-12-13T01:43:06.519Z level=DEBUG msg="no compaction" level=1 path=/gotosocial/sqlite.db
time=2025-12-13T01:43:06.613Z level=ERROR msg="monitor error" db=sqlite.db replica=s3 error="write ltx file: s3: upload to db/0000/0000000000000001-0000000000000001.ltx: upload multipart failed, upload id: 2~KQSv4kC57uibbEPFLoycIYFyGSmDSky, cause: operation error S3: UploadPart, https response error StatusCode: 403, RequestID: 54bb5be5-d558-291953972, HostID: , api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method."
time=2025-12-13T01:43:11.214Z level=DEBUG msg="enforcing l0 retention" db=sqlite.db retention=5m0s
time=2025-12-13T01:43:26.210Z level=DEBUG msg="enforcing l0 retention" db=sqlite.db retention=5m0s
```

## Environment
docker:
root@sfo3-01:~# docker image ls | grep lite
litestream/litestream             0.5        f8c8fd6a60b3   32 hours ago   44.5MB

litestream.yml is at beginning of this post.

**Litestream version:**
```text
v0.5.3
```

**Operating system & version:**
Debian 13 trixie

**Installation method:**
Docker

**Storage backend:**
S3

## Steps to Reproduce
Follow guide to use S3 on Digital Ocean Spaces:
https://litestream.io/guides/digitalocean/

**Expected behavior:**
replicate to Digital Ocean Spaces

**Actual behavior:**
Some kind of signature error when trying to upload files from litestream.

## Configuration
```yaml
access-key-id: redacted
secret-access-key: redacted

dbs:
  - path: /gotosocial/sqlite.db
    replica:
      url: s3://redacted.sfo3.digitaloceanspaces.com/db
```

</details>

## Logs
<!--- Include relevant error messages or logs -->
<!--- For more detail, run litestream with -trace flag -->
<!--- For long logs, consider using https://gist.github.com -->
<details><summary>Log output</summary>

```text
time=2025-12-13T01:43:06.613Z level=ERROR msg="monitor error" db=sqlite.db replica=s3 error="write ltx file: s3: upload to db/0000/0000000000000001-0000000000000001.ltx: upload multipart failed, upload id: 2~KQSv4kC57uibbEPFLoycIYFyGSmDSky, cause: operation error S3: UploadPart, https response error StatusCode: 403, RequestID: 54bb5be5-d558-291953972, HostID: , api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method."
```

</details>

I use s3cmd with exactly the same key/secret from same host to upload copies of sqlite file to S3. I do this once per day via cron.

Hints from issue comments:
@jiqiren: forgot to add for the purpose of this report I changed my config to turn on debug logging:
```syslog
access-key-id: redacted
secret-access-key: redacted

logging:
  level: debug # options: debug | info | warn | error

dbs:
  - path: /gotosocial/sqlite.db
    replica:
      url: s3://dustyninja-backup.sfo3.digitaloceanspaces.com/db

```

Already included my bucket name above so leaving it here now (it's fine - bucket is empty).@jiqiren: I was able to get working using v0.3.13. I tried v0.5.3 with exactly same config and still get error above.

```syslog
logging:
  level: info # options: debug | info | warn | error

dbs:
  - path: /gotosocial/sqlite.db
    replicas:
      - type: s3
        bucket: dustyninja-backup
        path: sqlite-litestream
        endpoint: sfo3.digitaloceanspaces.com
        region: sfo3
        access-key-id: KEYID
        secret-access-key: SECRETKEY

```@mzmcgrat: I encountered the same issue when I upgraded to the latest version of Litestream.  I am using a self-hosted instance of Minio as my S3 endpoint, and adding the new config option `sign-payload: true` seems to have solved the issue for me.@plttn: Thanks for listing the config option! Backblaze B2 now also requires `sign-payload: true`. @mzmcgrat: Maybe the documentation/examples for the various s3-compatible endpoints, and especially this table: https://litestream.io/reference/config/#s3-compatible-provider-requirements should be updated, since apparently both B2 and Minio require `sign-payload: true` now.@MohamedBassem: I wonder if this should get called out in the release notes. Because litestream was working fine without `sign-payload: true` on `0.5.2` and only after upgrading to `0.5.3` did it start failing (and it seems like a new config option anyway).@jiqiren: Thanks for all the comments everyone! I never saw the option `sign-payload: true` at all to know about it. This was my first experience / attempt to use litestream and it was pretty frustrating. Will be switching over to `v0.5.3` and add in `sign-payload: true`.@kalbasit: I got hit with this as well using my MinIO instance; Adding `sign-payload: true` fixed my issue.@mprokopiev: Following the #895, setting `sign-payload` to true doesn't with Filebase:
```
# cat /etc/litestream.yml
logging:
  level: TRACE
dbs:
  - path: ${NTFY_AUTH_FILE}
    replica:
      url: s3://litestream/ntfyv5
      endpoint: https://s3.filebase.com
      sign-payload: true
# litestream replicate -exec "ntfy serve"
....
time=2025-12-14T20:03:08.646+02:00 level=ERROR msg="monitor error" db=user.db replica=s3 error="write ltx file: s3: upload to ntfyv5/0000/0000000000000001-0000000000000001.ltx: operation error S3: PutObject, https response error StatusCode: 403, RequestID: 0a3cb7a127c2944767cbf0153fdb3c9f, HostID: ZmlsZWJhc2UtNmI5Y2M2OTY5Yy03Nnc4cQ==, api error AccessDenied: Access Denied"
```
It isn't creds issue as 0.3.13 binary works with the same creds.@corylanou: PR #899 addresses this issue with a comprehensive fix for S3-compatible providers:

1. **Auto-detection**: Automatically detects DigitalOcean Spaces and applies `sign-payload: true` by default
2. **aws-chunked fix**: Disables aws-chunked encoding for all custom endpoints (not just unsigned payloads)
3. **Provider defaults**: Auto-applies appropriate settings for DigitalOcean, Backblaze B2, Filebase, Scaleway, and MinIO

Once merged, DigitalOcean Spaces should work without any manual configuration. The `sign-payload: true` workaround will no longer be needed.@MohamedBassem: Thanks for the fix @corylanou, just want to call out that cloudflare's R2 also required the flag. Might be useful to auto detect it as well.@corylanou: @MohamedBassem Cloudflare R2 auto-detection has been added in PR #899. R2 endpoints (`*.r2.cloudflarestorage.com`) will now automatically have `SignPayload=true` applied.

Thanks for the suggestion!

Solution patch:
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..2b74b1d 100644
--- a/replica_url.go
+++ b/replica_url.go
@@ -189,16 +189,93 @@ 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"
+}
+
+// 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") {
+		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..7236865 100644
--- a/s3/replica_client.go
+++ b/s3/replica_client.go
@@ -154,8 +154,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 +178,9 @@ 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
 		if !signPayloadSet {
 			signPayload, signPayloadSet = true, true
 		}
@@ -186,6 +188,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
@@ -552,12 +574,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..17b8bca 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,10 +1304,6 @@ 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
diff --git a/replica_url_test.go b/replica_url_test.go
index b77125a..57c4368 100644
--- a/replica_url_test.go
+++ b/replica_url_test.go
@@ -630,3 +630,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..254e13e 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()