Self-Hosted S3 Static Website with Garage, Nginx, and GitLab CI/CD
Self-Hosted S3 Static Website with Garage, Nginx, and GitLab CI/CD
Learn how to set up your own S3-compatible static website hosting using Garage, Nginx, and automate deployments with GitLab CI/CD. This guide uses Ansible for infrastructure automation and demonstrates everything with the domain molokov.de.
Prerequisites
- Debian/Ubuntu server with root access
- Domain name (we’ll use
molokov.de) - Ansible installed locally
- GitLab account (for CI/CD)
1. Install Docker with Ansible
First, create an Ansible playbook to install Docker:
---
- name: Install Docker
hosts: webserver
become: yes
tasks:
- name: Install dependencies
apt:
name:
- ca-certificates
- curl
state: present
update_cache: yes
- name: Add Docker GPG key
shell: |
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
args:
creates: /etc/apt/keyrings/docker.asc
- name: Add Docker repository
apt_repository:
repo: deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable
state: present
- name: Install Docker
apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-compose-plugin
state: present
update_cache: yes
- name: Start Docker service
systemd:
name: docker
state: started
enabled: yes
Test:
ansible-playbook -i inventory.ini install-docker.yml
ssh root@your-server "docker --version"
2. Install and Configure Garage
Create directories and configuration for Garage:
- name: Setup Garage S3
hosts: webserver
become: yes
tasks:
- name: Create Garage directories
file:
path: "{{ item }}"
state: directory
mode: '0755'
loop:
- /etc/garage
- /var/lib/garage
- name: Generate RPC secret
shell: openssl rand -hex 32
register: rpc_secret
run_once: true
- name: Create Garage configuration
copy:
dest: /etc/garage/garage.toml
content: |
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "0.0.0.0:3901"
rpc_public_addr = "127.0.0.1:3901"
rpc_secret = "{{ rpc_secret.stdout }}"
[s3_api]
s3_region = "garage"
api_bind_addr = "0.0.0.0:3900"
root_domain = ".molokov.de"
[s3_web]
bind_addr = "0.0.0.0:3902"
root_domain = ".molokov.de"
- name: Run Garage container
docker_container:
name: garage
image: dxflrs/garage:v1.0.1
state: started
restart_policy: unless-stopped
ports:
- "3900:3900"
- "3902:3902"
volumes:
- /var/lib/garage:/var/lib/garage
- /etc/garage/garage.toml:/etc/garage.toml
- name: Wait for Garage to start
wait_for:
port: 3900
delay: 5
Test:
ansible-playbook -i inventory.ini setup-garage.yml
ssh root@your-server "docker exec garage /garage status"
3. Configure Garage Layout and Create Bucket
- name: Configure Garage bucket
hosts: webserver
become: yes
tasks:
- name: Get Garage node ID
shell: docker exec garage /garage node id -q
register: node_id
- name: Configure Garage layout
shell: |
docker exec garage /garage layout assign -z dc1 -c 10G {{ node_id.stdout }}
docker exec garage /garage layout apply --version 1
ignore_errors: yes
- name: Create access key
shell: docker exec garage /garage key create static-web
register: garage_key
ignore_errors: yes
- name: Create bucket for molokov.de
shell: |
docker exec garage /garage bucket create molokov.de
docker exec garage /garage bucket website --allow molokov.de
docker exec garage /garage bucket allow --read --write --key static-web molokov.de
ignore_errors: yes
Save the credentials from output:
ansible-playbook -i inventory.ini configure-garage.yml
# Note the Access Key ID and Secret Key from output
Test:
ssh root@your-server "docker exec garage /garage bucket list"
4. Install and Configure Nginx
- name: Setup Nginx
hosts: webserver
become: yes
tasks:
- name: Install nginx and certbot
apt:
name:
- nginx
- certbot
- python3-certbot-nginx
state: present
update_cache: yes
- name: Configure nginx for molokov.de
copy:
dest: /etc/nginx/sites-available/molokov.de
content: |
server {
listen 80;
server_name molokov.de;
location / {
proxy_pass http://127.0.0.1:3902;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
- name: Enable site
file:
src: /etc/nginx/sites-available/molokov.de
dest: /etc/nginx/sites-enabled/molokov.de
state: link
- name: Restart nginx
systemd:
name: nginx
state: restarted
enabled: yes
Test:
ansible-playbook -i inventory.ini setup-nginx.yml
curl -I http://molokov.de
5. Configure SSL with Let’s Encrypt
- name: Setup SSL
hosts: webserver
become: yes
tasks:
- name: Obtain SSL certificate
shell: certbot --nginx -d molokov.de --non-interactive --agree-tos --email admin@molokov.de --redirect
args:
creates: /etc/letsencrypt/live/molokov.de/fullchain.pem
Test:
ansible-playbook -i inventory.ini setup-ssl.yml
curl -I https://molokov.de
# Should return 200 OK with SSL certificate
6. Configure Firewall (Hetzner Cloud)
If using Hetzner Cloud, configure firewall via CLI:
# Install hcloud CLI
wget https://github.com/hetznercloud/cli/releases/latest/download/hcloud-linux-amd64.tar.gz
tar xf hcloud-linux-amd64.tar.gz
sudo mv hcloud /usr/local/bin/
# Configure firewall
hcloud context create my-project
# Enter your API token
# Get your IP
MY_IP=$(curl -s ifconfig.me)
# Create firewall
hcloud firewall create --name web-firewall
# Allow SSH, HTTP, HTTPS from your IP
hcloud firewall add-rule web-firewall --direction in --protocol tcp --source-ips $MY_IP/32 --port any
# Allow HTTP and HTTPS from anywhere
hcloud firewall add-rule web-firewall --direction in --protocol tcp --source-ips 0.0.0.0/0 --port 80
hcloud firewall add-rule web-firewall --direction in --protocol tcp --source-ips 0.0.0.0/0 --port 443
# Apply to server
hcloud firewall apply-to-resource web-firewall --type server --server your-server-name
Test:
curl -I https://molokov.de
# Should work from any location
7. Create Default Page for Static Hosting
Create a welcome page and upload to Garage:
- name: Upload default page
hosts: localhost
tasks:
- name: Create index.html
copy:
dest: /tmp/index.html
content: |
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>molokov.de</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #2a2a2a;
padding: 10%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
background: white;
width: 90%;
height: 90vh;
padding: 10%;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
h1 { font-size: 2.5em; margin-bottom: 1em; color: #333; }
p { font-size: 1.2em; line-height: 1.8; color: #555; margin-bottom: 1em; }
</style>
</head>
<body>
<div class="container">
<h1>Welcome to molokov.de</h1>
<p>This site is hosted on self-hosted Garage S3 storage.</p>
</div>
</body>
</html>
- name: Upload to S3
shell: |
export AWS_ACCESS_KEY_ID=YOUR_ACCESS_KEY
export AWS_SECRET_ACCESS_KEY=YOUR_SECRET_KEY
aws s3 cp /tmp/index.html s3://molokov.de/index.html --endpoint-url http://YOUR_SERVER_IP:3900
Test:
ansible-playbook -i inventory.ini upload-default-page.yml
curl https://molokov.de
# Should display the welcome page
8. GitLab CI/CD Pipeline for Automated Deployment
Step 1: Add GitLab CI/CD Variables
In your GitLab project, go to Settings → CI/CD → Variables and add:
S3_ENDPOINT_URL:http://YOUR_SERVER_IP:3900AWS_ACCESS_KEY_ID: Your Garage access keyAWS_SECRET_ACCESS_KEY: Your Garage secret keyS3_BUCKET:molokov.de
Mark AWS_SECRET_ACCESS_KEY as masked and protected.
Step 2: Create .gitlab-ci.yml
stages:
- deploy
deploy:
stage: deploy
image: klakegg/hugo:latest
before_script:
- apk add --no-cache python3 py3-pip
- pip3 install awscli
script:
# Generate Hugo site
- hugo --minify
# Upload to S3
- aws s3 sync ./public s3://$S3_BUCKET/ --endpoint-url $S3_ENDPOINT_URL --delete
- echo "Site generated and deployed to https://molokov.de"
only:
- main
Key changes:
- Combined build and deploy into single stage
- Hugo generates site first (
hugo --minify) - AWS CLI installed in the same container
- Generated
public/directory uploaded immediately to S3
Step 3: Test the Pipeline
# Commit and push changes
git add .
git commit -m "Add CI/CD pipeline"
git push origin main
# Check pipeline status in GitLab
# Visit: https://gitlab.com/your-username/your-project/-/pipelines
Test deployment:
# After pipeline completes
curl https://molokov.de
# Should show your updated content
Complete Ansible Playbook
Here’s the complete playbook combining all steps:
---
- name: Setup Self-Hosted S3 Static Website
hosts: webserver
become: yes
vars:
domain: molokov.de
server_ip: "{{ ansible_host }}"
tasks:
# Docker installation
- name: Install Docker dependencies
apt:
name: [ca-certificates, curl]
state: present
update_cache: yes
- name: Add Docker GPG key
shell: |
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
args:
creates: /etc/apt/keyrings/docker.asc
- name: Add Docker repository
apt_repository:
repo: deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable
state: present
- name: Install Docker
apt:
name: [docker-ce, docker-ce-cli, containerd.io, docker-compose-plugin]
state: present
update_cache: yes
- name: Start Docker
systemd:
name: docker
state: started
enabled: yes
# Garage setup
- name: Create Garage directories
file:
path: "{{ item }}"
state: directory
loop: [/etc/garage, /var/lib/garage]
- name: Generate RPC secret
shell: openssl rand -hex 32
register: rpc_secret
run_once: true
- name: Create Garage config
copy:
dest: /etc/garage/garage.toml
content: |
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "0.0.0.0:3901"
rpc_public_addr = "127.0.0.1:3901"
rpc_secret = "{{ rpc_secret.stdout }}"
[s3_api]
s3_region = "garage"
api_bind_addr = "0.0.0.0:3900"
root_domain = ".{{ domain }}"
[s3_web]
bind_addr = "0.0.0.0:3902"
root_domain = ".{{ domain }}"
- name: Run Garage
docker_container:
name: garage
image: dxflrs/garage:v1.0.1
state: started
restart_policy: unless-stopped
ports: ["3900:3900", "3902:3902"]
volumes:
- /var/lib/garage:/var/lib/garage
- /etc/garage/garage.toml:/etc/garage.toml
- name: Wait for Garage
wait_for:
port: 3900
delay: 5
# Nginx setup
- name: Install nginx and certbot
apt:
name: [nginx, certbot, python3-certbot-nginx]
state: present
- name: Configure nginx
copy:
dest: /etc/nginx/sites-available/{{ domain }}
content: |
server {
listen 80;
server_name {{ domain }};
location / {
proxy_pass http://127.0.0.1:3902;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
- name: Enable site
file:
src: /etc/nginx/sites-available/{{ domain }}
dest: /etc/nginx/sites-enabled/{{ domain }}
state: link
- name: Restart nginx
systemd:
name: nginx
state: restarted
# SSL setup
- name: Obtain SSL certificate
shell: certbot --nginx -d {{ domain }} --non-interactive --agree-tos --email admin@{{ domain }} --redirect
args:
creates: /etc/letsencrypt/live/{{ domain }}/fullchain.pem
Troubleshooting
Garage not starting
docker logs garage
# Check for RPC secret format errors
Nginx 502 Bad Gateway
# Check if Garage is running
docker ps | grep garage
# Test Garage directly
curl http://localhost:3902 -H "Host: molokov.de"
SSL certificate fails
# Ensure ports 80 and 443 are open
sudo ufw status
# Check DNS propagation
dig molokov.de A
Upload fails from GitLab CI/CD
# Verify credentials in GitLab variables
# Test manually:
export AWS_ACCESS_KEY_ID=your_key
export AWS_SECRET_ACCESS_KEY=your_secret
aws s3 ls s3://molokov.de --endpoint-url http://YOUR_IP:3900