Post

Run Garage S3 object storage in a homelab

A practical Garage S3 deployment with a protected web UI and an unbroken S3 API route.

Run Garage S3 object storage in a homelab

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:

HostPurposeAuth
s3.<your-domain>human web UIAuthentik
s3api.<your-domain>S3 APIS3 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:

  1. You are using https://s3api.<your-domain>, not the web UI host.
  2. The secret key has no hidden whitespace or newline.
  3. The bucket name is correct.
  4. The region is garage or your configured region.
  5. The client is using S3v4 signatures.
  6. Path-style addressing is enabled if your client struggles with custom domains.
  7. 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

These posts connect to this topic and help build the bigger homelab picture:

This post is licensed under CC BY 4.0 by the author.