Run Garage S3 object storage in a homelab
A practical Garage S3 deployment with a protected web UI and an unbroken S3 API route.
Garage is a lightweight S3-compatible object storage server. It is a strong fit for homelabs because it can run as a simple single-node object store, while still giving you S3-style buckets and access keys.
This setup exposes two different things:
| Host | Purpose | Auth |
|---|---|---|
s3.<your-domain> | human web UI | Authentik |
s3api.<your-domain> | S3 API | S3 signatures |
That separation matters. Browser SSO is good for dashboards. It is not good for signed S3 API requests.
Folder layout
1
2
3
4
5
6
/home/ubuntu/s3bucket/
├── docker-compose.yml
├── .env
├── garage.toml
├── meta/
└── data/
Garage config
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
compression_level = 2
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "127.0.0.1:3901"
rpc_secret = "<generate-a-long-random-secret>"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3api.<your-domain>"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.s3.<your-domain>"
index = "index.html"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "<generate-a-long-random-token>"
metrics_token = "<generate-another-token>"
Compose file
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
services:
garage:
image: dxflrs/garage:v2.3.0
container_name: garage-s3
restart: unless-stopped
volumes:
- ./garage.toml:/etc/garage.toml:ro
- ./meta:/var/lib/garage/meta
- ./data:/var/lib/garage/data
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.garage-s3api-secure.entrypoints=https"
- "traefik.http.routers.garage-s3api-secure.rule=Host(`s3api.<your-domain>`)"
- "traefik.http.routers.garage-s3api-secure.tls=true"
- "traefik.http.routers.garage-s3api-secure.tls.certresolver=cloudflare"
- "traefik.http.routers.garage-s3api-secure.service=garage-s3api"
- "traefik.http.services.garage-s3api.loadbalancer.server.port=3900"
webui:
image: khairul169/garage-webui:latest
container_name: garage-webui
restart: unless-stopped
volumes:
- ./garage.toml:/etc/garage.toml:ro
environment:
CONFIG_PATH: /etc/garage.toml
API_BASE_URL: http://garage-s3:3903
S3_ENDPOINT_URL: https://s3api.<your-domain>
S3_REGION: garage
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.garage-webui-secure.entrypoints=https"
- "traefik.http.routers.garage-webui-secure.rule=Host(`s3.<your-domain>`)"
- "traefik.http.routers.garage-webui-secure.middlewares=authentik@docker"
- "traefik.http.routers.garage-webui-secure.tls=true"
- "traefik.http.routers.garage-webui-secure.tls.certresolver=cloudflare"
- "traefik.http.routers.garage-webui-secure.service=garage-webui"
- "traefik.http.services.garage-webui.loadbalancer.server.port=3909"
networks:
proxy:
external: true
Initialize Garage
1
2
3
cd /home/ubuntu/s3bucket
docker compose up -d garage
docker exec garage-s3 /garage status
For a single-node lab, assign the node layout:
1
2
docker exec garage-s3 /garage layout assign <node-id> -z dc1 -c 1G
docker exec garage-s3 /garage layout apply --version <next-version>
Create a key and bucket:
1
2
3
docker exec garage-s3 /garage key create "default access key"
docker exec garage-s3 /garage bucket create novelox
docker exec garage-s3 /garage bucket allow novelox --key <access-key-id> --read --write --owner
Store the key ID and secret in .env, but never publish them.
Client configuration
Use:
1
2
3
4
Endpoint: https://s3api.<your-domain>
Region: garage
Bucket: novelox
Path-style: enabled/preferred
Example with Python/Boto3:
1
2
3
4
5
6
7
8
9
10
11
12
13
import boto3
from botocore.config import Config
s3 = boto3.client(
"s3",
endpoint_url="https://s3api.<your-domain>",
aws_access_key_id="<access-key-id>",
aws_secret_access_key="<secret-key>",
region_name="garage",
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
)
print(s3.list_objects_v2(Bucket="novelox", MaxKeys=10))
Fixing Invalid signature
If you get:
1
2
3
S3: ListObjectsV2
StatusCode: 403
AccessDenied: Forbidden: Invalid signature
Check these first:
- You are using
https://s3api.<your-domain>, not the web UI host. - The secret key has no hidden whitespace or newline.
- The bucket name is correct.
- The region is
garageor your configured region. - The client is using S3v4 signatures.
- Path-style addressing is enabled if your client struggles with custom domains.
- Authentik is not applied to the S3 API router.
A correct unauthenticated request to the API root often returns 403 XML. That is normal. It means the API route is reachable and waiting for valid S3 auth.
Why only the Web UI uses Authentik
S3 clients sign requests using the host, path, date, region, and secret key. If a reverse proxy changes the request or inserts an interactive login redirect, the signature no longer matches.
So the safe pattern is:
1
2
s3.<your-domain> -> Authentik -> Web UI
s3api.<your-domain> -> native S3 auth only
Related documentation
These posts connect to this topic and help build the bigger homelab picture:
- Build a homelab auth gateway with Traefik and Authentik — explains the shared Traefik and Authentik gateway pattern used to protect dashboards.
- Build a Jekyll documentation site for your homelab — shows the documentation workflow used to publish guides like this one.
- Self-host WordPress with Redis, MySQL, Docker Compose and Traefik — uses the same Docker Compose and Traefik routing model for a public web app.