Garage로 셀프호스팅 S3 구축하기
S3 호환 스토리지가 필요해 Garage를 올리고, 웹 어드민과 Cloudflare 터널까지 붙여 운영한 과정을 정리했습니다.
Garage를 서비스로 올리고 웹 어드민과 Cloudflare 터널까지 붙여 운영하며 정리한 실전 기록입니다.
객체 스토리지가 필요할 때마다 매번 AWS S3나 R2를 쓰는 게 정답은 아니었습니다. GreedyLabs는 이미 단일 Proxmox VM 위에서 Docker로 여러 서비스를 굴리고 있었고, 여기에 S3 호환 스토리지 하나를 얹어 블로그 이미지, 정적 파일, 각종 아티팩트를 자체적으로 두고 싶었습니다. 그래서 선택한 게 Garage입니다.
이 글은 Garage가 어떤 물건인지 간단히 짚은 뒤, Docker Compose로 올리고 웹 UI 어드민을 붙이고 Cloudflare 터널을 통해 S3 엔드포인트와 웹 엔드포인트를 각각 노출하기까지의 과정을 다룹니다. 특히 Garage가 Host 헤더를 어떻게 해석하는지, 그리고 그에 맞춰 DNS와 터널을 어떻게 설계했는지에 초점을 맞췄습니다. 이미 GreedyLabs 인프라는 Dockhand 기반 GitOps로 관리되고 있어서, Garage 역시 같은 흐름에 태워 배포했습니다.

Garage가 어떤 스토리지인가
Garage는 Deuxfleurs라는 프랑스의 비영리 호스팅 협동조합이 자체 인프라를 이용하기 위해 만든 오픈소스 분산 객체 스토리지입니다. Rust로 작성됐고, S3 API 호환을 목표로 설계됐습니다. 핵심 지향점이 지리적으로 흩어진, 성능이 고르지 않은 저사양 노드들을 묶어 신뢰할 수 있는 스토리지를 만든다는 것이라, 거대한 데이터센터가 아니라 홈랩이나 소규모 셀프호스팅 환경에 특히 잘 맞습니다.
같은 자리에 자주 놓고 비교되는 게 MinIO입니다. 둘 다 S3 호환 오브젝트 스토리지지만 지향이 다릅니다. MinIO는 고성능·엔터프라이즈 지향으로 기능이 풍부한 대신 무겁고, 근래 라이선스와 커뮤니티 기능 축소를 두고 셀프호스터들 사이에서 논쟁이 있었습니다. Garage는 그 반대편에서 가볍고 단순한 쪽을 택합니다.
GreedyLabs 관점에서 Garage를 고른 이유를 정리하면 이렇습니다.
- 가볍습니다. 바이너리 하나에 메타데이터/데이터 디렉토리만 지정하면 뜨고, 유휴 상태에서 리소스를 거의 먹지 않습니다. 단일 VM에 다른 서비스들과 함께 얹기에 부담이 없었습니다.
- 설정이 단순합니다. TOML 한 장으로 S3 API, 정적 웹 서빙, admin API를 모두 켤 수 있습니다. 특히 버킷을 정적 웹사이트로 바로 서빙하는 website hosting을 기본 지원해서, 블로그용 정적 파일을 버킷에 올려두고 웹으로 내보내는 시나리오가 깔끔했습니다.
- 라이선스가 명확합니다. AGPLv3 오픈소스로, 상업적 기능 게이팅 없이 전체 기능을 씁니다.
물론 감안할 점도 있습니다. Garage는 의도적으로 기능을 좁게 가져가는 프로젝트라, MinIO 같은 풍부한 관리 콘솔이나 IAM 세분화, 고급 S3 기능 지원은 상대적으로 제한적입니다(쓰려는 기능은 버전 문서로 확인하는 게 좋습니다). 공식 웹 어드민이 없어서 관리를 CLI로 하거나 서드파티 UI에 의존해야 하는 것도 그중 하나입니다. 뒤에서 다룰 웹 어드민이 바로 이 지점을 메우는 선택이었습니다.
단일 노드로 쓸 거라 replication_factor = 1로 두었습니다. 여러 노드로 확장하며 이중화까지 노린다면 이 값과 레이아웃 설계를 처음부터 신경 써야 하지만, 소규모 단일 VM 환경에서는 1로 충분했습니다.
Docker Compose 구성
Garage 본체와 제3자 제공 웹 UI 어드민(garage-webui)을 함께 띄웠습니다.
name: garage
services:
garage-webui:
image: khairul169/garage-webui:latest
container_name: garage-webui
pull_policy: always
restart: unless-stopped
depends_on:
- garage
environment:
TZ: Asia/Seoul
API_BASE_URL: http://garage:3903
S3_ENDPOINT_URL: http://garage:3900
API_ADMIN_KEY: ${GARAGE_ADMIN_TOKEN}
CONFIG_PATH: /etc/garage.toml
volumes:
- ./garage.toml:/etc/garage.toml:ro
networks:
- internal
ports:
- "3909:3909"
garage:
image: dxflrs/garage:${GARAGE_VERSION:-v2.3.0}
container_name: garage
pull_policy: always
restart: unless-stopped
environment:
TZ: Asia/Seoul
GARAGE_RPC_SECRET: ${GARAGE_RPC_SECRET}
GARAGE_ADMIN_TOKEN: ${GARAGE_ADMIN_TOKEN}
GARAGE_METRICS_TOKEN: ${GARAGE_METRICS_TOKEN:-}
volumes:
- ./garage.toml:/etc/garage.toml:ro
- garage-meta:/var/lib/garage/meta
- garage-data:/var/lib/garage/data
networks:
- internal
ports:
- "3900:3900"
- "3902:3902"
networks:
internal:
volumes:
garage-meta:
garage-data:
몇 가지 설계 의도를 짚어두면, 시크릿 값(GARAGE_RPC_SECRET, GARAGE_ADMIN_TOKEN 등)은 전부 환경변수로 주입해서 설정 파일에 하드코딩하지 않았습니다. garage.toml은 두 컨테이너에 모두 read-only로 마운트되어 어드민 UI가 같은 설정을 참조합니다. 그리고 컨테이너 간 통신은 internal 네트워크로 격리했습니다. garage-webui가 API_BASE_URL로 http://garage:3903(admin API)을 바라보는 것도 이 네트워크 덕분입니다.
포트는 admin(3903)만 호스트로 노출하지 않고, S3 API(3900)·웹 엔드포인트(3902)·웹 어드민(3909)을 열었습니다. 실제 외부 노출은 포트 매핑이 아니라 Cloudflare 터널이 담당합니다.
garage.toml 설정
핵심은 [s3_api]와 [s3_web]의 root_domain입니다.
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "lmdb"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "127.0.0.1:3901"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.greedylabs.kr"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".greedylabs.kr"
index = "index.html"
[admin]
api_bind_addr = "[::]:3903"
웹 엔드포인트는 .greedylabs.kr을 root_domain으로 잡아 <bucket>.greedylabs.kr 형태로 서빙하고, S3 API는 s3_region을 garage로 지정했습니다. 이 region 값은 나중에 클라이언트 서명 검증에 그대로 쓰이니 기억해둡니다. S3 엔드포인트는 Path Style을 사용해서 지정은 되어 있지만, 실제로는 쓰지 않습니다.
Garage가 Host 헤더를 읽는 방식
DNS와 터널을 어떻게 설계할지는 결국 Garage가 요청의 Host 헤더를 어떻게 버킷으로 매핑하는지에 달려 있습니다. 이걸 이해하고 나서야 DNS를 어떻게 붙일지 판단이 섰습니다.
웹 엔드포인트([s3_web]) 는 두 가지 방식으로 Host를 해석합니다. root_domain(.greedylabs.kr)에 매칭되면 subdomain을 버킷명으로 추출하고(blog.greedylabs.kr → blog 버킷), root_domain 밖의 Host는 값 전체를 버킷명으로 간주합니다. 즉 myblog.example.com이라는 이름의 버킷을 만들면 그 커스텀 도메인으로 바로 서빙됩니다.
S3 API([s3_api]) 는 addressing style이 두 가지입니다. path-style은 s3.greedylabs.kr/<bucket>/<key>처럼 버킷이 경로에 들어가고, virtual-hosted-style은 <bucket>.s3.greedylabs.kr/<key>처럼 버킷이 subdomain에 들어갑니다. 후자를 쓰려면 와일드카드 DNS가 필요합니다.
| 엔드포인트 | Host 예시 | 매핑되는 버킷 |
|---|---|---|
| 웹 (subdomain) | blog.greedylabs.kr |
blog |
| 웹 (전체 Host) | myblog.example.com |
myblog.example.com |
| S3 (path-style) | s3.greedylabs.kr/photos/... |
photos |
| S3 (vhost-style) | photos.s3.greedylabs.kr/... |
photos |
DNS 전략
편의상 웹과 S3의 노출 전략을 다르게 가져갔습니다.
웹은 커스텀 도메인
웹 도메인은 커스텀으로 하나씩 연동 하기로 했습니다. 정적 사이트를 서빙하는 버킷은 그리 자주 늘어나지 않고, 각 사이트마다 도메인을 명시적으로 관리하는 게 오히려 파악하기 편했습니다. 버킷 하나 늘 때마다 터널 라우팅을 직접 추가하는 수고는 감수할 만했습니다.
S3는 path-style 단일 엔드포인트
S3는 path-style 단일 엔드포인트(s3.greedylabs.kr)만 노출 하기로 했습니다. path-style이면 버킷이 몇 개가 되든 s3.greedylabs.kr 하나로 전부 커버되고, 버킷명은 URL 경로(s3.greedylabs.kr/<bucket>/...)로 들어가니 DNS를 더 손댈 일이 없습니다. virtual-hosted-style을 쓰려면 와일드카드 DNS를 붙여야 하는데, Cloudflare 터널에서는 이를 기피하기 때문에 가급적 지금의 환경에 맞춰서 선택했습니다.
Cloudflare 터널 연동
터널 설정은 전부 Cloudflare 웹 콘솔(Zero Trust 대시보드)에서 관리합니다. 여기서 먼저 알아둘 점이 하나 있습니다. 각 항목은 외부에 노출할 hostname(Subdomain + Domain)과 cloudflared가 프록시할 로컬 Service 주소를 짝지어 등록하는 구조입니다. Garage의 경우 다음처럼 붙였습니다.
| Public Hostname | Service |
|---|---|
s3.greedylabs.kr |
http://localhost:3900 |
garage.greedylabs.kr |
http://localhost:3909 |
demo-hugo.greedylabs.kr |
http://localhost:3902 |
S3 API(3900)는 path-style 단일 호스트로만 노출하고, 웹 엔드포인트(3902)는 커스텀 도메인마다 항목을 하나씩 추가합니다. 웹 어드민(3909)은 garage.greedylabs.kr로 노출했습니다.
버킷 이름과 도메인이 직접 매핑되는 것도 좋지만, 저는 이걸 수동으로 관리하기 위해서 HTTP Host Header를 덮어쓰도록 설정했습니다. 그래서 실제로는 bucket-demo-hugo 버킷으로 연결하기 위해서 실제 도메인과 다른 호스트 헤더인 bucket-demo-hugo 를 직접 선언해서 도메인-버킷 연동 구조를 만들었습니다. 이미 greedylabs.kr 하위에 많은 서브도메인이 있어서 1대1 대응 구조를 갖출 수 없기 때문에 도메인-버킷을 직접 지정해서 연결하기로 한 것입니다.
웹 UI 어드민
앞서 말했듯 Garage는 공식 웹 어드민을 제공하지 않고 CLI(garage 명령)를 기본으로 씁니다. 다만 커뮤니티에서 만든 서드파티 웹 UI가 몇 개 있는데, 그중 khairul169/garage-webui 이미지를 써봤고 만족스러웠습니다. 레이아웃 확인, 버킷 생성, 액세스 키 관리, website 설정 토글을 브라우저에서 처리할 수 있습니다.

동작 구조는 단순합니다. API_BASE_URL로 admin API(3903)에, S3_ENDPOINT_URL로 S3 API(3900)에 붙고, API_ADMIN_KEY에 admin 토큰을 넘겨 인증합니다. 컨테이너는 3909 포트로 UI를 서빙하고, 이걸 garage.greedylabs.kr로 터널에 붙여 외부에서 접근하게 했습니다. admin API는 토큰이 없으면 아무것도 못 하므로 UI 자체를 노출해도 큰 부담은 없었습니다. 다만 공식 제공이 아닌 서드파티 이미지인 만큼, 어디까지 신뢰 경계를 둘지는 각자 판단할 부분입니다. 필요하면 Cloudflare Access로 이 어드민 앞단에 인증을 한 겹 더 씌우는 것도 방법입니다.
초기 세팅에서 놓치기 쉬운 게 website 활성화입니다. 버킷을 만들었다고 웹으로 바로 서빙되는 게 아니라, 해당 버킷에 website 설정을 켜줘야(garage bucket website --allow <bucket>, 또는 UI에서 토글) 웹 엔드포인트가 응답합니다.
CLI로 확인하기
구성을 마쳤으면 실제 S3 클라이언트로 붙어봐야 합니다. AWS CLI로 접근을 확인했습니다. 웹 UI에서 키를 하나 생성해서 설정한 다음에 아래와 같이 커맨드를 입력하면 버킷 내의 파일 목록이 나타나는 것을 확인할 수 있습니다.
aws s3 ls s3://bucket-demo-hugo/ \
--endpoint-url https://s3.greedylabs.kr \
--region garage
PRE ananke/
PRE categories/
PRE images/
PRE posts/
PRE tags/
2026-07-06 15:53:33 5609 404.html
2026-07-06 15:53:33 8270 index.html
2026-07-06 15:53:33 1970 index.xml
2026-07-06 15:53:33 624 sitemap.xml
--endpoint-url에는 버킷 없는 베이스 URL만 넣고, 버킷은 s3:// 인자로 붙입니다. --region은 garage.toml의 s3_region 값(여기선 garage)과 일치해야 서명 검증이 통과합니다. 이 값이 안 맞으면 연결은 되는데 SignatureDoesNotMatch가 떨어지니, 엉뚱한 데서 헤매지 않으려면 처음부터 맞춰두는 게 좋습니다.
위 결과는 Hugo로 빌드한 정적 사이트를 통째로 올려둔 bucket-demo-hugo 버킷입니다. 빌드 산출물(public/ 디렉토리)을 그대로 동기화해두면, 이 버킷에 website 설정을 켜고 웹 엔드포인트에 커스텀 도메인을 붙이는 것만으로 정적 사이트 호스팅이 됩니다. https://demo-hugo.greedylabs.kr
hugo --minify
aws s3 sync ./public/ s3://bucket-demo-hugo/ \
--endpoint-url https://s3.greedylabs.kr \
--region garage --delete
S3 API로는 배포(업로드), 웹 엔드포인트로는 서빙까지, Garage 하나로 이 두 역할을 모두 처리하는 셈입니다.
마치며
정리하면 Garage 구축에서 가장 중요한 결정은 Host 헤더와 DNS를 어떻게 짝지을지였습니다. 웹은 사이트별로 도메인을 눈에 보이게 관리하고 싶어서 커스텀 도메인을 하나씩, S3는 버킷이 늘어도 손이 안 가도록 path-style 단일 엔드포인트로 나눈 게 GreedyLabs 환경에는 잘 맞았습니다.
이제 Self-hosted S3 호환 서버가 생겼으므로, 기존에 S3를 연동해서 이용하는 모든 서비스는 S3 설정 위치에 Garage의 액세스 키와 리전을 입력함으로써 동등하게 동작하는 환경을 가지게 되었습니다. 가볍게 내 인프라에 S3를 두고 싶은 분들께 참고 레퍼런스가 되면 좋겠습니다.