S3-Compatible Storage
    Single Binary

    Deploy Garage S3-Compatible Object Storage on a VPS

    Self-host Garage, a lightweight Rust S3-compatible object store, on a RamNode VPS with TLS, systemd hardening, firewall rules, and a path to multi-node clustering.

    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

    WorkloadvCPURAMDisk
    Backup target, light traffic11 GB40 GB+
    General object storage22 GB80 GB+
    High object count or heavy concurrency44 GB160 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.com for the S3 API and *.web.example.com for static site hosting
    • Ports 80 and 443 open

    Step 1: Prepare the server

    Update the system and install the tools you will need.

    shell
    sudo apt update && sudo apt upgrade -y
    sudo apt install -y curl wget openssl ca-certificates

    Set a hostname so cluster node names are readable later.

    shell
    sudo hostnamectl set-hostname garage01

    Create a dedicated system user. Garage does not need root at runtime.

    shell
    sudo useradd --system --no-create-home --shell /usr/sbin/nologin garage

    Create the metadata and data directories.

    shell
    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/data

    Step 2: Install the Garage binary

    Garage ships static musl binaries. Download v2.3.0 for x86_64.

    shell
    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/garage

    Verify the install.

    shell
    garage --version

    You 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.

    shell
    openssl rand -hex 32   # RPC secret
    openssl rand -base64 32   # admin token
    openssl rand -base64 32   # metrics token

    Keep 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.

    shell
    sudo nano /etc/garage.toml

    Paste the following and substitute your own secrets and public IP.

    shell
    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 = 1 is correct for a single node. Garage refuses to start if the factor exceeds the number of nodes with capacity.
    • replication_mode was removed in Garage v2.0. If you are following an older tutorial, replication_factor plus consistency_mode is the replacement.
    • Binding the S3, web, and admin listeners to 127.0.0.1 keeps them private. The reverse proxy in Step 7 handles public traffic and TLS.
    • rpc_bind_addr stays on all interfaces so future cluster nodes can reach it. Lock port 3901 down with the firewall in Step 6.
    • root_domain under [s3_api] enables virtual-hosted-style requests such as bucket.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.

    shell
    sudo chown garage:garage /etc/garage.toml
    sudo chmod 600 /etc/garage.toml

    Step 5: Create the systemd service

    Create /etc/systemd/system/garage.service.

    shell
    sudo nano /etc/systemd/system/garage.service
    shell
    [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.target

    Enable and start it.

    shell
    sudo systemctl daemon-reload
    sudo systemctl enable --now garage
    sudo systemctl status garage

    The garage CLI reads /etc/garage.toml to find the RPC socket and secret, so run administrative commands with sudo.

    shell
    sudo garage status

    You 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.

    shell
    sudo garage node id

    Assign 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.

    shell
    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 1

    Confirm the cluster is healthy.

    shell
    sudo garage status
    sudo garage stats

    Garage 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.

    shell
    sudo ufw allow OpenSSH
    sudo ufw allow 80/tcp
    sudo ufw allow 443/tcp
    sudo ufw --force enable

    When you add a second node later, open 3901 only to that node's address.

    shell
    sudo ufw allow from 198.51.100.20 to any port 3901 proto tcp

    Step 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.

    shell
    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 caddy

    Edit /etc/caddy/Caddyfile.

    shell
    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.

    shell
    sudo systemctl reload caddy

    nginx alternative. If you already run nginx, the critical directives are:

    shell
    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.

    shell
    sudo garage key create backups-key

    Garage prints the key ID and secret. The secret is shown once. Retrieve it later with:

    shell
    sudo garage key info backups-key --show-secret

    Create a bucket and grant the key access.

    shell
    sudo garage bucket create backups
    sudo garage bucket allow --read --write --owner backups --key backups-key
    sudo garage bucket info backups

    Optionally set a size or object quota.

    shell
    sudo garage bucket set-quotas backups --max-size 50G --max-objects 1000000

    Step 10: Test with the AWS CLI

    Install the CLI.

    shell
    sudo apt install -y awscli

    Configure a named profile.

    shell
    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: json

    Garage requires the S3v4 signature and does not implement every AWS extension, so add this to ~/.aws/config under the profile:

    shell
    [profile garage]
    region = garage
    request_checksum_calculation = when_required
    response_checksum_validation = when_required

    Upload and list an object.

    shell
    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.txt

    Step 11: Point Restic at your bucket

    Garage's most common job on a small VPS is holding backups. Restic works out of the box.

    shell
    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 --prune

    Wrap 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.

    shell
    sudo garage bucket create mysite
    sudo garage bucket website --allow mysite
    sudo garage bucket alias mysite mysite.web.example.com

    Upload 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.

    shell
    sudo garage meta snapshot --all

    Snapshots land under /var/lib/garage/meta/snapshots/. Copy them off the box.

    Health and repair

    shell
    sudo garage stats
    sudo garage worker list
    sudo garage repair --yes tables
    sudo garage repair --yes blocks

    Run repair blocks after any unclean shutdown or disk incident.

    Backups

    Back up three things:

    1. /etc/garage.toml, which holds the RPC secret
    2. A recent metadata snapshot
    3. /var/lib/garage/data if 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.

    shell
    curl -H "Authorization: Bearer <metrics_token>" http://127.0.0.1:3903/metrics

    Add a Prometheus scrape job with a bearer token, then import the Grafana dashboard published in the Garage repository.

    Upgrading

    shell
    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 status

    Patch 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:

    1. Install the same Garage version on the new VPS
    2. Copy /etc/garage.toml and keep rpc_secret identical, changing rpc_public_addr to the new node's IP
    3. Open port 3901 between the two nodes only
    4. Connect the nodes:
    shell
    # 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
    1. Assign the new node to a different zone and apply a new layout version:
    shell
    sudo garage layout assign -z dc2 -c 60G <node2-id>
    sudo garage layout apply --version 2
    1. Raise replication_factor to 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