This guide provides a full Ansible playbook to deploy Shelf with Docker Compose on Debian 10+, Ubuntu LTS, and RHEL 9+ compatible hosts. Note that Shelf requires a Supabase backend for authentication and database functionality, which adds complexity to the self-hosted deployment.
[!WARNING]
Shelf requires Supabase for authentication and database functionality. Unlike many self-hosted applications, Shelf does not include its own database layer. You must either:
- Use the Supabase cloud service (recommended for ease of setup)
- Self-host your own Supabase instance (more complex but fully self-contained)
- name: Deploy Shelf
hosts: shelf
become: true
vars:
app_root: /opt/shelf
app_port: 3000
# Supabase configuration - replace with your actual values
supabase_url: "https://your-project.supabase.co"
supabase_anon_public: "your-anon-public-key"
supabase_service_role: "your-service-role-key"
database_url: "postgres://postgres:password@your-project.supabase.co:6543/postgres"
direct_url: "postgres://postgres:password@your-project.supabase.co:5432/postgres"
session_secret: "your-session-secret"
server_url: "https://shelf.yourdomain.com"
# Optional features
smtp_host: "smtp.yourdomain.com"
smtp_port: 587
smtp_user: "noreply@yourdomain.com"
smtp_password: "your-smtp-password"
maptiler_token: ""
tasks:
- name: Install Docker on Debian/Ubuntu
apt:
name:
- docker.io
- docker-compose-plugin
state: present
update_cache: true
when: ansible_os_family == "Debian"
- name: Install Docker on RHEL family
dnf:
name:
- docker
- docker-compose-plugin
state: present
when: ansible_os_family == "RedHat"
- name: Add docker group membership for ansible user
user:
name: "{{ ansible_user }}"
groups: docker
append: true
notify: restart ssh
- name: Enable and start Docker
systemd:
name: docker
state: started
enabled: true
- name: Create application directory
file:
path: "{{ app_root }}"
state: directory
mode: "0755"
owner: "{{ ansible_user }}"
- name: Create media directory
file:
path: "{{ app_root }}/media"
state: directory
mode: "0755"
owner: "{{ ansible_user }}"
- name: Write environment file
template:
src: shelf.env.j2
dest: "{{ app_root }}/.env"
mode: "0600"
owner: "{{ ansible_user }}"
vars:
supabase_url: "{{ supabase_url }}"
supabase_anon_public: "{{ supabase_anon_public }}"
supabase_service_role: "{{ supabase_service_role }}"
database_url: "{{ database_url }}"
direct_url: "{{ direct_url }}"
session_secret: "{{ session_secret }}"
server_url: "{{ server_url }}"
smtp_host: "{{ smtp_host }}"
smtp_port: "{{ smtp_port }}"
smtp_user: "{{ smtp_user }}"
smtp_password: "{{ smtp_password }}"
maptiler_token: "{{ maptiler_token }}"
- name: Write Docker Compose file
template:
src: docker-compose.yml.j2
dest: "{{ app_root }}/docker-compose.yml"
mode: "0644"
owner: "{{ ansible_user }}"
notify: restart shelf
- name: Start application stack
docker_compose:
project_src: "{{ app_root }}"
state: present
become: yes
become_user: "{{ ansible_user }}"
handlers:
- name: restart shelf
docker_compose:
project_src: "{{ app_root }}"
state: present
become: yes
become_user: "{{ ansible_user }}"
- name: restart ssh
service:
name: ssh
state: restarted
Create the following templates in your Ansible roles directory:
# Supabase Configuration
DATABASE_URL={{ database_url }}
DIRECT_URL={{ direct_url }}
SUPABASE_ANON_PUBLIC={{ supabase_anon_public }}
SUPABASE_SERVICE_ROLE={{ supabase_service_role }}
SUPABASE_URL={{ supabase_url }}
# Application Configuration
SERVER_URL={{ server_url }}
SESSION_SECRET={{ session_secret }}
INVITE_TOKEN_SECRET={{ session_secret }}_invite
# Optional Features
{% if maptiler_token %}
MAPTILER_TOKEN={{ maptiler_token }}
{% endif %}
{% if smtp_host %}
SMTP_HOST={{ smtp_host }}
SMTP_PORT={{ smtp_port }}
SMTP_USER={{ smtp_user }}
SMTP_FROM="Shelf Notifications <{{ smtp_user }}>"
SMTP_PWD={{ smtp_password }}
SMTP_SECURE=false
{% endif %}
# Production Settings
NODE_ENV=production
LOG_LEVEL=info
MAX_REQUEST_SIZE=10mb
ENABLE_TELEMETRY=false
TRUST_PROXY_HEADERS=true
version: '3.8'
services:
shelf:
image: ghcr.io/shelf-nu/shelf.nu:latest
restart: unless-stopped
ports:
- "{{ app_port }}:8080"
env_file:
- .env
volumes:
- ./media:/app/public/media
environment:
- NODE_ENV=production
For production deployments, consider using a reverse proxy:
- name: Deploy Shelf with Nginx Reverse Proxy
hosts: shelf
become: true
vars:
app_root: /opt/shelf
# ... (same variables as above)
tasks:
- name: Install Docker and Nginx
block:
- name: Install Docker on Debian/Ubuntu
apt:
name:
- docker.io
- docker-compose-plugin
- nginx
state: present
update_cache: true
when: ansible_os_family == "Debian"
- name: Install Docker on RHEL family
dnf:
name:
- docker
- docker-compose-plugin
- nginx
state: present
when: ansible_os_family == "RedHat"
- name: Enable and start services
systemd:
name: "{{ item }}"
state: started
enabled: true
loop:
- docker
- nginx
# ... (previous tasks continued)
- name: Write Nginx configuration
template:
src: shelf.nginx.conf.j2
dest: "/etc/nginx/sites-available/shelf"
mode: "0644"
notify: reload nginx
- name: Enable Nginx site
file:
src: "/etc/nginx/sites-available/shelf"
dest: "/etc/nginx/sites-enabled/shelf"
state: link
notify: reload nginx
- name: Disable default Nginx site
file:
path: "/etc/nginx/sites-enabled/default"
state: absent
notify: reload nginx
handlers:
- name: reload nginx
systemd:
name: nginx
state: reloaded
# ... (previous handlers continued)
server {
listen 80;
server_name {{ server_url | regex_replace('https?://', '') }};
# Redirect HTTP to HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name {{ server_url | regex_replace('https?://', '') }};
# SSL Configuration
ssl_certificate /path/to/your/certificate.crt;
ssl_certificate_key /path/to/your/private.key;
# Security Headers
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:;";
location / {
proxy_pass http://127.0.0.1:{{ app_port }};
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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_cache_bypass $http_upgrade;
# Increase buffer sizes for file uploads
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
}
}
latestStore sensitive information in an encrypted vault:
# group_vars/shelf/vault.yml
vault_supabase_anon_public: !vault |
$ANSIBLE_VAULT;1.1;AES256
[encrypted content]
vault_supabase_service_role: !vault |
$ANSIBLE_VAULT;1.1;AES256
[encrypted content]
vault_database_url: !vault |
$ANSIBLE_VAULT;1.1;AES256
[encrypted content]
vault_session_secret: !vault |
$ANSIBLE_VAULT;1.1;AES256
[encrypted content]
Any questions?
Feel free to contact us. Find all contact information on our contact page.