Slug: deploy-garage-s3-object-storage-vps
Difficulty: Intermediate
Estimated time: 45 minutes
Tested on: Ubuntu 24.04 LTS, Garage v2.3.0
Introduction
Garage is an S3-compatible object storage service written in Rust. It was built by Deuxfleurs for self-hosted, geo-distributed deployments on cheap commodity hardware, which makes it a natural fit for a budget VPS. Compared to MinIO, Garage uses a fraction of the memory, ships as a single static binary with no runtime dependencies, and stays under the AGPLv3 license with no community-edition feature stripping.
Typical use cases on a RamNode VPS:
- Backup target for Restic, Kopia, or Duplicati
- Media and asset storage behind a CDN
- Terraform or OpenTofu remote state backend
- Static website hosting with the built-in web endpoint
- Loki, Thanos, or Mimir object storage backend
This guide covers a production single-node deployment with TLS, systemd hardening, and firewall rules, then shows how to grow into a multi-node cluster.
RamNode VPS sizing
| Workload | vCPU | RAM | Disk |
|---|---|---|---|
| Backup target, light traffic | 1 | 1 GB | 40 GB+ |
| General object storage | 2 | 2 GB | 80 GB+ |
| High object count or heavy concurrency | 4 | 4 GB | 160 GB+ |
Garage itself idles around 100 MB of RAM. Plan capacity around your data volume, not around the process. Put the metadata directory on SSD. If you separate data onto a large HDD volume, keep metadata on the fast disk.
Prerequisites
- A RamNode KVM VPS running Ubuntu 24.04 LTS
- Root or sudo access
- A domain with DNS pointed at your VPS. This guide uses
s3.example.comfor the S3 API and*.web.example.comfor static site hosting - Ports 80 and 443 open
Step 1: Prepare the server
Update the system and install the tools you will need.
sudo apt update && sudo apt upgrade -y
sudo apt install -y curl wget openssl ca-certificatesSet a hostname so cluster node names are readable later.
sudo hostnamectl set-hostname garage01Create a dedicated system user. Garage does not need root at runtime.
sudo useradd --system --no-create-home --shell /usr/sbin/nologin garageCreate the metadata and data directories.
sudo mkdir -p /var/lib/garage/meta /var/lib/garage/data
sudo chown -R garage:garage /var/lib/garage
sudo chmod 700 /var/lib/garage/meta /var/lib/garage/dataStep 2: Install the Garage binary
Garage ships static musl binaries. Download v2.3.0 for x86_64.
wget https://garagehq.deuxfleurs.fr/_releases/v2.3.0/x86_64-unknown-linux-musl/garage -O /tmp/garage
sudo install -m 755 /tmp/garage /usr/local/bin/garage
rm /tmp/garageVerify the install.
garage --versionYou should see garage v2.3.0 followed by the compiled feature list, including lmdb, sqlite, k2v, and metrics.
For ARM64 VPS instances, substitute aarch64-unknown-linux-musl in the download URL.
Step 3: Generate secrets
Garage needs two secrets: an RPC secret shared by every node in the cluster, and an admin API token.
openssl rand -hex 32 # RPC secret
openssl rand -base64 32 # admin token
openssl rand -base64 32 # metrics tokenKeep these somewhere safe. The RPC secret must be identical on every node you later add to the cluster.
Step 4: Write the configuration file
Create /etc/garage.toml.
sudo nano /etc/garage.tomlPaste the following and substitute your own secrets and public IP.
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "lmdb"
replication_factor = 1
consistency_mode = "consistent"
compression_level = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "203.0.113.10:3901"
rpc_secret = "REPLACE_WITH_OPENSSL_RAND_HEX_32"
[s3_api]
s3_region = "garage"
api_bind_addr = "127.0.0.1:3900"
root_domain = ".s3.example.com"
[s3_web]
bind_addr = "127.0.0.1:3902"
root_domain = ".web.example.com"
index = "index.html"
[admin]
api_bind_addr = "127.0.0.1:3903"
admin_token = "REPLACE_WITH_ADMIN_TOKEN"
metrics_token = "REPLACE_WITH_METRICS_TOKEN"Notes on the important values:
replication_factor = 1is correct for a single node. Garage refuses to start if the factor exceeds the number of nodes with capacity.replication_modewas removed in Garage v2.0. If you are following an older tutorial,replication_factorplusconsistency_modeis the replacement.- Binding the S3, web, and admin listeners to
127.0.0.1keeps them private. The reverse proxy in Step 7 handles public traffic and TLS. rpc_bind_addrstays on all interfaces so future cluster nodes can reach it. Lock port 3901 down with the firewall in Step 6.root_domainunder[s3_api]enables virtual-hosted-style requests such asbucket.s3.example.com. Path-style requests work regardless.
Garage refuses to load a config file containing secrets if the file is world readable. Set ownership and permissions.
sudo chown garage:garage /etc/garage.toml
sudo chmod 600 /etc/garage.tomlStep 5: Create the systemd service
Create /etc/systemd/system/garage.service.
sudo nano /etc/systemd/system/garage.service[Unit]
Description=Garage S3-compatible object storage
Documentation=https://garagehq.deuxfleurs.fr/documentation/
After=network-online.target
Wants=network-online.target
[Service]
Type=notify
User=garage
Group=garage
ExecStart=/usr/local/bin/garage -c /etc/garage.toml server
StateDirectory=garage
Restart=on-failure
RestartSec=5
LimitNOFILE=65536
# Hardening
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true
RestrictSUIDSGID=true
MemoryDenyWriteExecute=true
LockPersonality=true
ReadWritePaths=/var/lib/garage
[Install]
WantedBy=multi-user.targetEnable and start it.
sudo systemctl daemon-reload
sudo systemctl enable --now garage
sudo systemctl status garageThe garage CLI reads /etc/garage.toml to find the RPC socket and secret, so run administrative commands with sudo.
sudo garage statusYou will see one node listed with the role NO ROLE ASSIGNED. That is expected until you apply a layout.
Step 6: Assign the cluster layout
Garage will not accept writes until a storage layout is applied. Grab the node ID from the status output.
sudo garage node idAssign the node to a zone with a capacity. Capacity is the usable space you want Garage to consume, not the total disk size. Leave headroom.
NODE_ID=$(sudo garage node id -q | cut -d@ -f1)
sudo garage layout assign -z dc1 -c 60G "$NODE_ID"
sudo garage layout show
sudo garage layout apply --version 1Confirm the cluster is healthy.
sudo garage status
sudo garage statsGarage v2.3.0 also supports garage server --single-node, which autocreates a layout on first boot. The manual path above is preferable in production because you control the zone name and capacity from the start.
Step 7: Configure the firewall
Allow SSH and web traffic. Keep the RPC port closed unless you are building a cluster.
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enableWhen you add a second node later, open 3901 only to that node's address.
sudo ufw allow from 198.51.100.20 to any port 3901 proto tcpStep 8: Put a reverse proxy in front
Garage does not terminate TLS. Use Caddy for the shortest path to working certificates, including the wildcard needed for virtual-hosted-style buckets.
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
| sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install -y caddyEdit /etc/caddy/Caddyfile.
s3.example.com {
reverse_proxy 127.0.0.1:3900 {
transport http {
read_timeout 3600s
write_timeout 3600s
}
}
request_body {
max_size 0
}
}
*.s3.example.com {
reverse_proxy 127.0.0.1:3900
request_body {
max_size 0
}
tls {
dns cloudflare {env.CF_API_TOKEN}
}
}
*.web.example.com {
reverse_proxy 127.0.0.1:3902
tls {
dns cloudflare {env.CF_API_TOKEN}
}
}The wildcard hosts need a DNS-01 challenge, which requires a Caddy build that includes your DNS provider plugin. If you would rather avoid that, drop the wildcard blocks and use path-style S3 addressing only. Every major S3 client supports it.
Reload Caddy.
sudo systemctl reload caddynginx alternative. If you already run nginx, the critical directives are:
server {
listen 443 ssl http2;
server_name s3.example.com;
ssl_certificate /etc/letsencrypt/live/s3.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/s3.example.com/privkey.pem;
client_max_body_size 0;
proxy_request_buffering off;
proxy_buffering off;
location / {
proxy_pass http://127.0.0.1:3900;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}client_max_body_size 0 and proxy_request_buffering off are not optional. Without them, nginx buffers entire multipart uploads to disk and rejects large objects.
Step 9: Create a bucket and an access key
Create an access key.
sudo garage key create backups-keyGarage prints the key ID and secret. The secret is shown once. Retrieve it later with:
sudo garage key info backups-key --show-secretCreate a bucket and grant the key access.
sudo garage bucket create backups
sudo garage bucket allow --read --write --owner backups --key backups-key
sudo garage bucket info backupsOptionally set a size or object quota.
sudo garage bucket set-quotas backups --max-size 50G --max-objects 1000000Step 10: Test with the AWS CLI
Install the CLI.
sudo apt install -y awscliConfigure a named profile.
aws configure --profile garage
# AWS Access Key ID: <key id from step 9>
# AWS Secret Access Key: <secret from step 9>
# Default region name: garage
# Default output format: jsonGarage requires the S3v4 signature and does not implement every AWS extension, so add this to ~/.aws/config under the profile:
[profile garage]
region = garage
request_checksum_calculation = when_required
response_checksum_validation = when_requiredUpload and list an object.
echo "hello from RamNode" > test.txt
aws --profile garage --endpoint-url https://s3.example.com s3 cp test.txt s3://backups/
aws --profile garage --endpoint-url https://s3.example.com s3 ls s3://backups/
aws --profile garage --endpoint-url https://s3.example.com s3 rm s3://backups/test.txtStep 11: Point Restic at your bucket
Garage's most common job on a small VPS is holding backups. Restic works out of the box.
export AWS_ACCESS_KEY_ID=<key id>
export AWS_SECRET_ACCESS_KEY=<secret>
export RESTIC_REPOSITORY="s3:https://s3.example.com/backups"
export RESTIC_PASSWORD="a-long-random-passphrase"
restic init
restic backup /etc /home /var/www
restic snapshots
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --pruneWrap that in a systemd timer for nightly runs.
Step 12: Host a static site (optional)
Garage has a built-in web endpoint. Enable it per bucket.
sudo garage bucket create mysite
sudo garage bucket website --allow mysite
sudo garage bucket alias mysite mysite.web.example.comUpload your site and it is served at https://mysite.web.example.com. Objects must be publicly readable or served through a key-authenticated path, so set the bucket to allow anonymous reads if the site is public.
Operations
Metadata snapshots
The LMDB metadata store can be snapshotted online. Do this before upgrades.
sudo garage meta snapshot --allSnapshots land under /var/lib/garage/meta/snapshots/. Copy them off the box.
Health and repair
sudo garage stats
sudo garage worker list
sudo garage repair --yes tables
sudo garage repair --yes blocksRun repair blocks after any unclean shutdown or disk incident.
Backups
Back up three things:
/etc/garage.toml, which holds the RPC secret- A recent metadata snapshot
/var/lib/garage/dataif you have no replication elsewhere
Losing metadata while keeping data blocks means losing the object namespace. Metadata is small and matters most.
Monitoring
Garage exposes Prometheus metrics on the admin listener.
curl -H "Authorization: Bearer <metrics_token>" http://127.0.0.1:3903/metricsAdd a Prometheus scrape job with a bearer token, then import the Grafana dashboard published in the Garage repository.
Upgrading
sudo garage meta snapshot --all
sudo systemctl stop garage
wget https://garagehq.deuxfleurs.fr/_releases/vX.Y.Z/x86_64-unknown-linux-musl/garage -O /tmp/garage
sudo install -m 755 /tmp/garage /usr/local/bin/garage
sudo systemctl start garage
sudo garage statusPatch releases inside the v2 series carry no breaking changes. Moving from v1.x to v2.x does: the admin API moved to /v2/ endpoints and replication_mode was removed. Read the v2 release notes before that jump.
Growing into a multi-node cluster
Garage was designed for geo-distributed replication, which is where it beats every single-box alternative. To add a second node on another RamNode location:
- Install the same Garage version on the new VPS
- Copy
/etc/garage.tomland keeprpc_secretidentical, changingrpc_public_addrto the new node's IP - Open port 3901 between the two nodes only
- Connect the nodes:
# On node 2, get the full node identifier
sudo garage node id
# On node 1, connect to it
sudo garage node connect <node2-id>@198.51.100.20:3901- Assign the new node to a different zone and apply a new layout version:
sudo garage layout assign -z dc2 -c 60G <node2-id>
sudo garage layout apply --version 2- Raise
replication_factorto 2 or 3 in the config on all nodes and restart them one at a time
Garage places replicas in distinct zones, so zone naming controls your failure domains. Three nodes in three zones with replication_factor = 3 survives losing any single location.
Troubleshooting
Garage will not start, log says the config file is world readable.
Run sudo chmod 600 /etc/garage.toml and confirm the file is owned by the garage user.
garage status shows the node but every write returns a 503.
No layout has been applied. Run through Step 6.
Uploads over roughly 1 MB fail through the proxy.
Body buffering. Set client_max_body_size 0 and proxy_request_buffering off in nginx, or max_size 0 in Caddy.
SignatureDoesNotMatch errors from newer AWS SDKs.
Set request_checksum_calculation = when_required and response_checksum_validation = when_required in your AWS config. Recent SDKs default to sending checksum headers that older S3 implementations reject.
Virtual-hosted-style requests 404 but path-style works.
root_domain in [s3_api] does not match the hostname you are calling, or the wildcard DNS record is missing.
Node refuses to join the cluster. The RPC secret differs between nodes, or port 3901 is filtered. Check both before anything else.
Next steps
- Add lifecycle rules to expire old objects automatically
- Enable server-side encryption with SSE-C for sensitive buckets
- Front the S3 endpoint with a CDN for read-heavy public assets
- Point Loki, Thanos, or Mimir at Garage as their object storage backend
- Add a second RamNode instance in another location and move to
replication_factor = 3
