summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-07-09 21:49:56 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-07-09 21:49:56 -0700
commit1d043702ef136e8f2e516c5304a57be5718f1f6f (patch)
tree9b3ad070f11dad5994c58acd41a15db8043745aa
Initial commit: docker compose files for all self-hosted servicesHEADmaster
12 services: adguard, ampache, azuracast, flood, glance, glances, grafana, immich, invidious, jellyfin, openwebui, watchtower Each service includes a docker-compose.yml, README.md with setup instructions, and .env.example where applicable.
-rw-r--r--.gitignore54
-rw-r--r--README.md43
-rw-r--r--adguard/README.md24
-rw-r--r--adguard/docker-compose.yml29
-rw-r--r--ampache/.env.example15
-rw-r--r--ampache/README.md24
-rw-r--r--ampache/docker-compose.yml29
-rw-r--r--azuracast/.env.example48
-rw-r--r--azuracast/README.md34
-rw-r--r--azuracast/azuracast.env195
-rw-r--r--azuracast/docker-compose.override.yml6
-rw-r--r--azuracast/docker-compose.yml202
-rwxr-xr-xazuracast/docker.sh949
-rw-r--r--flood/README.md20
-rw-r--r--flood/docker-compose.yml17
-rw-r--r--glance/.env.example3
-rw-r--r--glance/README.md28
-rw-r--r--glance/docker-compose.yml16
-rw-r--r--glances/README.md21
-rw-r--r--glances/docker-compose.yml16
-rw-r--r--glances/glances.conf2
-rw-r--r--grafana/README.md23
-rw-r--r--grafana/docker-compose.yml21
-rw-r--r--grafana/prometheus.yml16
-rw-r--r--immich/.env.example24
-rw-r--r--immich/README.md31
-rwxr-xr-ximmich/backup.sh26
-rwxr-xr-ximmich/docker-compose.yml76
-rw-r--r--invidious/README.md28
-rw-r--r--invidious/config/sql/annotations.sql12
-rw-r--r--invidious/config/sql/channel_videos.sql30
-rw-r--r--invidious/config/sql/channels.sql25
-rw-r--r--invidious/config/sql/nonces.sql22
-rw-r--r--invidious/config/sql/playlist_videos.sql19
-rw-r--r--invidious/config/sql/playlists.sql29
-rw-r--r--invidious/config/sql/session_ids.sql23
-rw-r--r--invidious/config/sql/users.sql29
-rw-r--r--invidious/config/sql/videos.sql23
-rw-r--r--invidious/docker-compose.yml81
-rw-r--r--invidious/docker/Dockerfile81
-rwxr-xr-xinvidious/docker/init-invidious-db.sh12
-rw-r--r--jellyfin/.env.example2
-rw-r--r--jellyfin/README.md59
-rw-r--r--jellyfin/docker-compose.yml133
-rwxr-xr-xjellyfin/setup-media-user.sh75
-rw-r--r--openwebui/.env.example1
-rw-r--r--openwebui/README.md35
-rw-r--r--openwebui/docker-compose.yml38
-rw-r--r--watchtower/.env.example6
-rw-r--r--watchtower/README.md25
-rw-r--r--watchtower/docker-compose.yml19
51 files changed, 2799 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..baf8438
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,54 @@
+# Environment files with secrets
+.env
+!.env.example
+
+# Stack-specific data/config directories
+adguard/config/
+adguard/data/
+ampache/data/
+azuracast/azuracast.env_backup_*
+azuracast/.env_backup_*
+azuracast/docker-compose.backup.yml
+azuracast/.claude/
+flood/flood-config/
+glance/assets/
+glance/config/
+immich/config/
+immich/postgres/
+jellyfin/cache/
+jellyfin/config/
+jellyfin/jackett-config/
+jellyfin/prowlarr-config/
+jellyfin/radarr-config/
+jellyfin/recyclarr-config/
+jellyfin/sonarr-config/
+invidious/assets/
+invidious/config/config.example.yml
+invidious/config/migrate-scripts/
+invidious/.git/
+invidious/screenshots/
+invidious/locales/
+invidious/mocks/
+invidious/spec/
+invidious/src/
+invidious/scripts/
+invidious/CHANGELOG*.md
+invidious/.ameba.yml
+invidious/.editorconfig
+invidious/.gitattributes
+invidious/.github/
+invidious/.gitignore
+invidious/.gitmodules
+invidious/LICENSE
+invidious/Makefile
+invidious/shard.lock
+invidious/shard.yml
+invidious/TRANSLATION
+invidious/videojs-dependencies.yml
+invidious/kubernetes/
+invidious/invidious.service
+
+# System files
+.DS_Store
+*.swp
+*.swo
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..828af0f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,43 @@
+# Docker Stack
+
+Docker Compose files for my self-hosted services.
+
+## Services
+
+| Service | Description | Directory |
+|---------|------------|-----------|
+| AdGuard Home | DNS ad-blocker | [`adguard/`](adguard/) |
+| Ampache | Music streaming server | [`ampache/`](ampache/) |
+| AzuraCast | Web radio management | [`azuracast/`](azuracast/) |
+| Flood | Web UI for rTorrent | [`flood/`](flood/) |
+| Glance | Personal dashboard | [`glance/`](glance/) |
+| Glances | System monitoring | [`glances/`](glances/) |
+| Grafana + Prometheus | Metrics & monitoring | [`grafana/`](grafana/) |
+| Immich | Photo/video management | [`immich/`](immich/) |
+| Invidious | YouTube frontend | [`invidious/`](invidious/) |
+| Jellyfin + *arr Stack | Media server suite | [`jellyfin/`](jellyfin/) |
+| Open WebUI | LLM chat interface | [`openwebui/`](openwebui/) |
+| Watchtower | Automatic container updates | [`watchtower/`](watchtower/) |
+
+## Usage
+
+Each service directory contains:
+- `docker-compose.yml` — The compose file
+- `README.md` — Setup instructions and configuration guide
+- `.env.example` — Template for required environment variables (where applicable)
+
+To deploy a service, cd into its directory and follow its README.
+
+### Quick Start (any service)
+
+```bash
+cd <service>
+cp .env.example .env # if present — edit with your settings
+docker compose up -d
+```
+
+## Notes
+
+- All `.env` files are git-ignored. Each service provides a `.env.example` as a template.
+- Secrets and API keys in the compose files reference environment variables — provide them via `.env` files.
+- Volume paths and timezone settings are configured for my environment. Adjust them in the compose files or `.env` for your setup.
diff --git a/adguard/README.md b/adguard/README.md
new file mode 100644
index 0000000..40c7672
--- /dev/null
+++ b/adguard/README.md
@@ -0,0 +1,24 @@
+# AdGuard Home
+
+DNS ad-blocker and network-level tracker blocker.
+
+## Setup
+
+1. Create required directories:
+ ```
+ mkdir -p config data
+ ```
+
+2. Start:
+ ```
+ docker compose up -d
+ ```
+
+## Access
+
+- Web UI: `http://<host>:4000`
+
+## What to Configure
+
+- **Ports** — Port 53 (DNS) must be available. If something else on your system is already listening on port 53 (e.g. systemd-resolved), disable it or change the host ports.
+- **Subnet** — The network subnet (`172.30.0.0/24`) can be adjusted if it conflicts with your LAN.
diff --git a/adguard/docker-compose.yml b/adguard/docker-compose.yml
new file mode 100644
index 0000000..053605a
--- /dev/null
+++ b/adguard/docker-compose.yml
@@ -0,0 +1,29 @@
+services:
+ adguardhome:
+ image: adguard/adguardhome
+ container_name: adguardhome
+ restart: unless-stopped
+ volumes:
+ - ./data:/opt/adguardhome/work
+ - ./config:/opt/adguardhome/conf
+ ports:
+ - "53:53/tcp"
+ - "53:53/udp"
+ - "67:67/udp"
+ - "68:68/udp"
+ - "4000:4000/tcp"
+ - "853:853/tcp" # DNS-over-TLS
+ - "853:853/udp" # DNS-over-QUIC
+ - "5443:5443/tcp" # DNS-over-HTTPS (alt)
+ - "5443:5443/udp"
+ - "6060:6060/tcp" # pprof debug
+ networks:
+ - adguard
+
+networks:
+ adguard:
+ driver: bridge
+ ipam:
+ config:
+ - subnet: 172.30.0.0/24
+ gateway: 172.30.0.1
diff --git a/ampache/.env.example b/ampache/.env.example
new file mode 100644
index 0000000..375ffc7
--- /dev/null
+++ b/ampache/.env.example
@@ -0,0 +1,15 @@
+# Amplitude configuration
+MYSQL_USER=admin
+MYSQL_PASS=your_mysql_password_here
+# DISABLE_INOTIFYWAIT_CLEAN=0
+# DB_NAME=
+# DB_USER=
+# DB_PASSWORD=
+# DB_HOST=
+# DB_PORT=3306
+# FORCE_INSTALL=0
+# AMPACHE_DB_USER=
+# AMPACHE_DB_PASSWORD=
+# AMPACHE_ADMIN_USER=admin
+# AMPACHE_ADMIN_PASSWORD=**Random**
+# AMPACHE_ADMIN_EMAIL=
diff --git a/ampache/README.md b/ampache/README.md
new file mode 100644
index 0000000..503eefc
--- /dev/null
+++ b/ampache/README.md
@@ -0,0 +1,24 @@
+# Ampache
+
+Self-hosted music streaming server.
+
+## Setup
+
+1. Copy `.env.example` to `.env` and fill in your credentials:
+ ```
+ cp .env.example .env
+ ```
+
+2. Start:
+ ```
+ docker compose up -d
+ ```
+
+## Access
+
+- Web UI: `http://<host>:7676`
+
+## What to Configure
+
+- **`.env`** — Set `MYSQL_USER` and `MYSQL_PASS` before first start. Other variables can be set after the initial setup.
+- **Media volume** — The line `- /srv/mus:/media` mounts your music library. Change `/srv/mus` to wherever your music is stored.
diff --git a/ampache/docker-compose.yml b/ampache/docker-compose.yml
new file mode 100644
index 0000000..b7d398f
--- /dev/null
+++ b/ampache/docker-compose.yml
@@ -0,0 +1,29 @@
+name: ampache
+
+services:
+ ampache:
+ image: ampache/ampache:latest
+ container_name: ampache
+ restart: unless-stopped
+ ports:
+ - 7676:80
+ volumes:
+ - ./data/config:/var/www/config
+ - ./data/log:/var/log/ampache
+ - /srv/mus:/media
+ - ./data/mysql:/var/lib/mysql
+ environment:
+ MYSQL_USER: ${MYSQL_USER-admin}
+ MYSQL_PASS: ${MYSQL_PASS-**Random**}
+ DB_NAME: ${DB_NAME-}
+ DB_HOST: ${DB_HOST-}
+ DB_USER: ${DB_USER-}
+ DB_PASSWORD: ${DB_PASSWORD-}
+ DB_PORT: ${DB_PORT-}
+ DISABLE_INOTIFYWAIT_CLEAN: ${DISABLE_INOTIFYWAIT_CLEAN-0}
+ FORCE_INSTALL: ${FORCE_INSTALL-0}
+ AMPACHE_DB_USER: ${AMPACHE_DB_USER-}
+ AMPACHE_DB_PASSWORD: ${AMPACHE_DB_PASSWORD-}
+ AMPACHE_ADMIN_USER: ${AMPACHE_ADMIN_USER-}
+ AMPACHE_ADMIN_PASSWORD: ${AMPACHE_ADMIN_PASSWORD-}
+ AMPACHE_ADMIN_EMAIL: ${AMPACHE_ADMIN_EMAIL-}
diff --git a/azuracast/.env.example b/azuracast/.env.example
new file mode 100644
index 0000000..de840e3
--- /dev/null
+++ b/azuracast/.env.example
@@ -0,0 +1,48 @@
+# This file was automatically generated by AzuraCast.
+# You can modify it as necessary. To apply changes, restart the Docker containers.
+# Remove the leading "#" symbol from lines to uncomment them.
+
+# (Docker Compose) All Docker containers are prefixed by this name. Do not change this after installation.
+# Default: azuracast
+COMPOSE_PROJECT_NAME=azuracast
+
+# (Docker Compose) The amount of time to wait before a Docker Compose operation fails. Increase this on lower performance computers.
+# Default: 300
+COMPOSE_HTTP_TIMEOUT=300
+
+# Release Channel
+# Valid options: latest, stable
+# Default: latest
+AZURACAST_VERSION=latest
+
+# HTTP Port
+# The main port AzuraCast listens to for insecure HTTP connections.
+# Default: 80
+AZURACAST_HTTP_PORT=8080
+
+# HTTPS Port
+# The main port AzuraCast listens to for secure HTTPS connections.
+# Default: 443
+AZURACAST_HTTPS_PORT=8443
+
+# SFTP Port
+# The port AzuraCast listens to for SFTP file management connections.
+# Default: 2022
+# AZURACAST_SFTP_PORT=2022
+
+# Station Ports
+# The ports AzuraCast should listen to for station broadcasts and incoming DJ connections.
+# AZURACAST_STATION_PORTS=8000,8005,8006,...
+
+# Docker User UID
+# AZURACAST_PUID=1000
+
+# Docker User GID
+# AZURACAST_PGID=1000
+
+# Advanced: Use Privileged Docker Settings
+# Default: true
+# AZURACAST_COMPOSE_PRIVILEGED=true
+
+# Additional Environment Variables
+NGINX_TIMEOUT=1800
diff --git a/azuracast/README.md b/azuracast/README.md
new file mode 100644
index 0000000..dc0036f
--- /dev/null
+++ b/azuracast/README.md
@@ -0,0 +1,34 @@
+# AzuraCast
+
+Self-hosted web radio management suite. Runs stations, handles playlists, DJ accounts, streaming, and more.
+
+## Setup
+
+1. Copy `.env.example` to `.env`:
+ ```
+ cp .env.example .env
+ ```
+
+2. **Review `azuracast.env`** — This file contains database passwords and other internal config. Change `MYSQL_PASSWORD` and `MYSQL_ROOT_PASSWORD` to secure values.
+
+3. Start:
+ ```
+ docker compose up -d
+ ```
+
+ Or use the bundled helper script:
+ ```
+ bash docker.sh install
+ ```
+
+## Access
+
+- Web UI: `http://<host>:<AZURACAST_HTTP_PORT>` (default 8080)
+- HTTPS: `https://<host>:<AZURACAST_HTTPS_PORT>` (default 8443)
+
+## What to Configure
+
+- **`.env`** — Set `AZURACAST_HTTP_PORT`, `AZURACAST_HTTPS_PORT`, and optionally `AZURACAST_PUID`/`AZURACAST_PGID` to match your host user.
+- **`azuracast.env`** — Contains internal passwords and settings. Change at least the MySQL passwords.
+- **Media mounts** — See `docker-compose.override.yml` for an example of mounting external media into station directories.
+- **`docker.sh`** — The official AzuraCast management script for updates, backups, and maintenance tasks.
diff --git a/azuracast/azuracast.env b/azuracast/azuracast.env
new file mode 100644
index 0000000..8a45352
--- /dev/null
+++ b/azuracast/azuracast.env
@@ -0,0 +1,195 @@
+# This file was automatically generated by AzuraCast.
+# You can modify it as necessary. To apply changes, restart the Docker containers.
+# Remove the leading "#" symbol from lines to uncomment them.
+
+# The locale to use for CLI commands
+# Valid options: en_US, cs_CZ, nl_NL, fr_FR, de_DE, el_GR, it_IT, ja_JP, ko_KR, nb_NO, pl_PL, pt_PT, pt_BR, ru_RU, zh_CN, es_ES, sv_SE, tr_TR, uk_UA
+# Default: en_US
+LANG=en_US
+
+# The application environment
+# Valid options: Production, Testing, Development
+APPLICATION_ENV=production
+
+# Manually modify the logging level
+# This allows you to log debug-level errors temporarily (for problem-solving)
+# or reduce the volume of logs that are produced by your installation,
+# without needing to modify whether your installation is a production or
+# development instance.
+# Valid options: debug, info, notice, warning, error, critical, alert, emergency
+# LOG_LEVEL=
+
+# Enable Custom Code Plugins
+# Enable the composer "merge" functionality to combine the main application's
+# composer.json file with any plugin composer files. This can have
+# performance implications, so you should only use it if you use one or more
+# plugins with their own Composer dependencies.
+# Valid options: true, false
+COMPOSER_PLUGIN_MODE=false
+
+# Minimum Port for Station Port Assignment
+# Modify this if your stations are listening on nonstandard ports.
+AUTO_ASSIGN_PORT_MIN=8000
+
+# Maximum Port for Station Port Assignment
+# Modify this if your stations are listening on nonstandard ports.
+AUTO_ASSIGN_PORT_MAX=8499
+
+# Show Detailed Slim Application Errors
+# This allows you to debug Slim Application Errors you may encounter. By
+# default, this is disabled to prevent users from seeing privileged
+# information. Please report any Slim Application Error logs to the
+# development team on GitHub.
+# Valid options: true, false
+SHOW_DETAILED_ERRORS=false
+
+# MariaDB Host
+# Do not modify this after installation.
+# Default: localhost
+MYSQL_HOST=localhost
+
+# MariaDB Port
+# Do not modify this after installation.
+# Default: 3306
+MYSQL_PORT=3306
+
+# MariaDB Username
+# Do not modify this after installation.
+# Default: azuracast
+MYSQL_USER=azuracast
+
+# MariaDB Password
+# Do not modify this after installation.
+# Default: azur4c457
+MYSQL_PASSWORD=your_mysql_password_here
+
+# MariaDB Database Name
+# Do not modify this after installation.
+# Default: azuracast
+MYSQL_DATABASE=azuracast
+
+# Auto-generate Random MariaDB Root Password
+# Do not modify this after installation.
+# MYSQL_RANDOM_ROOT_PASSWORD=
+
+# MariaDB Root Password
+# Do not modify this after installation.
+MYSQL_ROOT_PASSWORD=your_root_password_here
+
+# Enable MariaDB Slow Query Log
+# Log slower queries to diagnose possible database issues. Only turn this on
+# if needed.
+# MYSQL_SLOW_QUERY_LOG=
+
+# MariaDB Maximum Connections
+# Set the amount of allowed connections to the database. This value should be
+# increased if you are seeing the "Too many connections" error in the logs.
+# Default: 100
+# MYSQL_MAX_CONNECTIONS=100
+
+# MariaDB InnoDB Buffer Pool Size
+# The InnoDB buffer pool size controls how much data & indexes are kept in
+# memory. Making sure that this value is as large as possible reduces the
+# amount of disk IO.
+# Default: 128M
+# MYSQL_INNODB_BUFFER_POOL_SIZE=128M
+
+# MariaDB InnoDB Log File Size
+# The InnoDB log file is used to achieve data durability in case of crashes
+# or unexpected shutoffs and to allow the DB to better optimize IO for write
+# operations.
+# Default: 16M
+# MYSQL_INNODB_LOG_FILE_SIZE=16M
+
+# Enable Redis
+# Disable to use a flatfile cache instead of Redis.
+# ENABLE_REDIS=
+
+# Redis Host
+# Default: localhost
+REDIS_HOST=localhost
+
+# Redis Port
+# Default: 6379
+REDIS_PORT=6379
+
+# Redis DB (Recommended to Use Default)
+# REDIS_DB=
+
+# PHP Maximum POST File Size
+# Default: 25M
+# PHP_MAX_FILE_SIZE=25M
+
+# PHP Memory Limit
+# Default: 128M
+# PHP_MEMORY_LIMIT=128M
+
+# PHP Script Maximum Execution Time (Seconds)
+# Default: 30
+# PHP_MAX_EXECUTION_TIME=30
+
+# Short Sync Task Execution Time (Seconds)
+# The maximum execution time (and lock timeout) for the 15-second, 1-minute
+# and 5-minute synchronization tasks.
+# SYNC_SHORT_EXECUTION_TIME=
+
+# Long Sync Task Execution Time (Seconds)
+# The maximum execution time (and lock timeout) for the 1-hour
+# synchronization task.
+# SYNC_LONG_EXECUTION_TIME=
+
+# Now Playing Delay Time (Seconds)
+# The delay (in seconds) between Now Playing checks for every station.
+# Decrease for more frequent checks at the expense of performance; increase
+# for less frequent checks but better performance (for large installations).
+# NOW_PLAYING_DELAY_TIME=
+
+# Now Playing Max Concurrent Processes
+# The maximum number of concurrent processes for now playing updates.
+# Increasing this can help reduce the latency between updates now playing
+# updates on large installations.
+# NOW_PLAYING_MAX_CONCURRENT_PROCESSES=
+
+# Maximum PHP-FPM Worker Processes
+# Default: 5
+# PHP_FPM_MAX_CHILDREN=5
+
+# Enable Performance Profiling Extension
+# Profiling data can be viewed by visiting
+# http://your-azuracast-site/?SPX_KEY=dev&SPX_UI_URI=/.
+# PROFILING_EXTENSION_ENABLED=
+
+# Profile Performance on All Requests
+# This will have a significant performance impact on your installation.
+# PROFILING_EXTENSION_ALWAYS_ON=
+
+# Profiling Extension HTTP Key
+# The value for the "SPX_KEY" parameter for viewing profiling pages.
+# PROFILING_EXTENSION_HTTP_KEY=
+
+# Profiling Extension IP Allow List
+# Valid options: 127.0.0.1, *
+# Default: *
+# PROFILING_EXTENSION_HTTP_IP_WHITELIST=*
+
+# Nginx Max Client Body Size
+# This is the total size any single request body can be. AzuraCast chunks its
+# uploads into smaller file sizes, so this only applies when doing custom
+# uploads via the API. Sizes should be listed in a format like "100K",
+# "128M", "1G" for kilobytes, megabytes, and gigabytes respectively.
+# Default: 50M
+# NGINX_CLIENT_MAX_BODY_SIZE=50M
+
+# Automatically block common bots and crawlers
+# If enabled, this will automatically download and update the Ultimate Nginx
+# Bad Bot Blocker, which will block aggressive crawlers and other bots.
+# Valid options: true, false
+NGINX_BLOCK_BOTS=false
+
+# Enable web-based Docker image updates
+# Default: true
+# ENABLE_WEB_UPDATER=true
+
+# Extra Ubuntu packages to install upon startup
+# Default: "Separate package names with a space. Packages will be installed during container startup."
+# INSTALL_PACKAGES_ON_STARTUP="Separate package names with a space. Packages will be installed during container startup."
diff --git a/azuracast/docker-compose.override.yml b/azuracast/docker-compose.override.yml
new file mode 100644
index 0000000..3a00cde
--- /dev/null
+++ b/azuracast/docker-compose.override.yml
@@ -0,0 +1,6 @@
+services:
+ web:
+ volumes:
+ - /srv/mus/:/var/azuracast/stations/cool_cats/media/
+ - /srv/mus/:/var/azuracast/stations/night_ride/media/
+ - /srv/mus/:/var/azuracast/stations/editors_cut/media/
diff --git a/azuracast/docker-compose.yml b/azuracast/docker-compose.yml
new file mode 100644
index 0000000..3bae85a
--- /dev/null
+++ b/azuracast/docker-compose.yml
@@ -0,0 +1,202 @@
+name: azuracast
+services:
+ web:
+ container_name: azuracast
+ image: 'ghcr.io/azuracast/azuracast:${AZURACAST_VERSION:-latest}'
+ labels:
+ - com.centurylinklabs.watchtower.scope=azuracast
+ ports:
+ - '${AZURACAST_HTTP_PORT:-80}:${AZURACAST_HTTP_PORT:-80}'
+ - '${AZURACAST_HTTPS_PORT:-443}:${AZURACAST_HTTPS_PORT:-443}'
+ - '${AZURACAST_SFTP_PORT:-2022}:${AZURACAST_SFTP_PORT:-2022}'
+ - '8000:8000'
+ - '8005:8005'
+ - '8006:8006'
+ - '8010:8010'
+ - '8015:8015'
+ - '8016:8016'
+ - '8020:8020'
+ - '8025:8025'
+ - '8026:8026'
+ - '8030:8030'
+ - '8035:8035'
+ - '8036:8036'
+ - '8040:8040'
+ - '8045:8045'
+ - '8046:8046'
+ - '8050:8050'
+ - '8055:8055'
+ - '8056:8056'
+ - '8060:8060'
+ - '8065:8065'
+ - '8066:8066'
+ - '8070:8070'
+ - '8075:8075'
+ - '8076:8076'
+ - '8090:8090'
+ - '8095:8095'
+ - '8096:8096'
+ - '8100:8100'
+ - '8105:8105'
+ - '8106:8106'
+ - '8110:8110'
+ - '8115:8115'
+ - '8116:8116'
+ - '8120:8120'
+ - '8125:8125'
+ - '8126:8126'
+ - '8130:8130'
+ - '8135:8135'
+ - '8136:8136'
+ - '8140:8140'
+ - '8145:8145'
+ - '8146:8146'
+ - '8150:8150'
+ - '8155:8155'
+ - '8156:8156'
+ - '8160:8160'
+ - '8165:8165'
+ - '8166:8166'
+ - '8170:8170'
+ - '8175:8175'
+ - '8176:8176'
+ - '8180:8180'
+ - '8185:8185'
+ - '8186:8186'
+ - '8190:8190'
+ - '8195:8195'
+ - '8196:8196'
+ - '8200:8200'
+ - '8205:8205'
+ - '8206:8206'
+ - '8210:8210'
+ - '8215:8215'
+ - '8216:8216'
+ - '8220:8220'
+ - '8225:8225'
+ - '8226:8226'
+ - '8230:8230'
+ - '8235:8235'
+ - '8236:8236'
+ - '8240:8240'
+ - '8245:8245'
+ - '8246:8246'
+ - '8250:8250'
+ - '8255:8255'
+ - '8256:8256'
+ - '8260:8260'
+ - '8265:8265'
+ - '8266:8266'
+ - '8270:8270'
+ - '8275:8275'
+ - '8276:8276'
+ - '8280:8280'
+ - '8285:8285'
+ - '8286:8286'
+ - '8290:8290'
+ - '8295:8295'
+ - '8296:8296'
+ - '8300:8300'
+ - '8305:8305'
+ - '8306:8306'
+ - '8310:8310'
+ - '8315:8315'
+ - '8316:8316'
+ - '8320:8320'
+ - '8325:8325'
+ - '8326:8326'
+ - '8330:8330'
+ - '8335:8335'
+ - '8336:8336'
+ - '8340:8340'
+ - '8345:8345'
+ - '8346:8346'
+ - '8350:8350'
+ - '8355:8355'
+ - '8356:8356'
+ - '8360:8360'
+ - '8365:8365'
+ - '8366:8366'
+ - '8370:8370'
+ - '8375:8375'
+ - '8376:8376'
+ - '8380:8380'
+ - '8385:8385'
+ - '8386:8386'
+ - '8390:8390'
+ - '8395:8395'
+ - '8396:8396'
+ - '8400:8400'
+ - '8405:8405'
+ - '8406:8406'
+ - '8410:8410'
+ - '8415:8415'
+ - '8416:8416'
+ - '8420:8420'
+ - '8425:8425'
+ - '8426:8426'
+ - '8430:8430'
+ - '8435:8435'
+ - '8436:8436'
+ - '8440:8440'
+ - '8445:8445'
+ - '8446:8446'
+ - '8450:8450'
+ - '8455:8455'
+ - '8456:8456'
+ - '8460:8460'
+ - '8465:8465'
+ - '8466:8466'
+ - '8470:8470'
+ - '8475:8475'
+ - '8476:8476'
+ - '8480:8480'
+ - '8485:8485'
+ - '8486:8486'
+ - '8490:8490'
+ - '8495:8495'
+ - '8496:8496'
+ env_file:
+ - azuracast.env
+ - .env
+ volumes:
+ - 'station_data:/var/azuracast/stations'
+ - 'backups:/var/azuracast/backups'
+ - 'db_data:/var/lib/mysql'
+ - 'www_uploads:/var/azuracast/storage/uploads'
+ - 'shoutcast2_install:/var/azuracast/storage/shoutcast2'
+ - 'stereo_tool_install:/var/azuracast/storage/stereo_tool'
+ - 'rsas_install:/var/azuracast/storage/rsas'
+ - 'geolite_install:/var/azuracast/storage/geoip'
+ - 'sftpgo_data:/var/azuracast/storage/sftpgo'
+ - 'acme:/var/azuracast/storage/acme'
+ restart: unless-stopped
+ ulimits:
+ nofile:
+ soft: 65536
+ hard: 65536
+ logging:
+ options:
+ max-size: 1m
+ max-file: '5'
+ updater:
+ container_name: azuracast_updater
+ image: 'ghcr.io/azuracast/updater:latest'
+ restart: unless-stopped
+ volumes:
+ - '/var/run/docker.sock:/var/run/docker.sock'
+ logging:
+ options:
+ max-size: 1m
+ max-file: '5'
+volumes:
+ db_data: {}
+ acme: {}
+ shoutcast2_install: {}
+ stereo_tool_install: {}
+ rsas_install: {}
+ geolite_install: {}
+ sftpgo_data: {}
+ station_data: {}
+ www_uploads: {}
+ backups: {}
diff --git a/azuracast/docker.sh b/azuracast/docker.sh
new file mode 100755
index 0000000..a494cd3
--- /dev/null
+++ b/azuracast/docker.sh
@@ -0,0 +1,949 @@
+#!/usr/bin/env bash
+# shellcheck disable=SC2145,SC2178,SC2120,SC2162
+
+PODMAN_MODE=0
+
+# Docker and Docker Compose aliases
+d() {
+ if [[ $PODMAN_MODE -ne 0 ]]; then
+ podman "$@"
+ else
+ docker "$@"
+ fi
+}
+
+dc() {
+ if [[ $PODMAN_MODE -ne 0 ]]; then
+ podman-compose "$@"
+ else
+ if [[ $(docker compose version) ]]; then
+ docker compose "$@"
+ else
+ docker-compose "$@"
+ fi
+ fi
+}
+
+# Functions to manage .env files
+__dotenv=
+__dotenv_file=
+__dotenv_cmd=.env
+
+.env() {
+ REPLY=()
+ [[ $__dotenv_file || ${1-} == -* ]] || .env.--file .env || return
+ if declare -F -- ".env.${1-}" >/dev/null; then
+ .env."$@"
+ return
+ fi
+ return 64
+}
+
+.env.-f() { .env.--file "$@"; }
+
+.env.get() {
+ .env::arg "get requires a key" "$@" &&
+ [[ "$__dotenv" =~ ^(.*(^|$'\n'))([ ]*)"$1="(.*)$ ]] &&
+ REPLY=${BASH_REMATCH[4]%%$'\n'*} && REPLY=${REPLY%"${REPLY##*[![:space:]]}"}
+}
+
+.env.parse() {
+ local line key
+ while IFS= read -r line; do
+ line=${line#"${line%%[![:space:]]*}"} # trim leading whitespace
+ line=${line%"${line##*[![:space:]]}"} # trim trailing whitespace
+ if [[ ! "$line" || "$line" == '#'* ]]; then continue; fi
+ if (($#)); then
+ for key; do
+ if [[ $key == "${line%%=*}" ]]; then
+ REPLY+=("$line")
+ break
+ fi
+ done
+ else
+ REPLY+=("$line")
+ fi
+ done <<<"$__dotenv"
+ ((${#REPLY[@]}))
+}
+
+.env.export() { ! .env.parse "$@" || export "${REPLY[@]}"; }
+
+.env.set() {
+ .env::file load || return
+ local key saved=$__dotenv
+ while (($#)); do
+ key=${1#+}
+ key=${key%%=*}
+ if .env.get "$key"; then
+ REPLY=()
+ if [[ $1 == +* ]]; then
+ shift
+ continue # skip if already found
+ elif [[ $1 == *=* ]]; then
+ __dotenv=${BASH_REMATCH[1]}${BASH_REMATCH[3]}$1$'\n'${BASH_REMATCH[4]#*$'\n'}
+ else
+ __dotenv=${BASH_REMATCH[1]}${BASH_REMATCH[4]#*$'\n'}
+ continue # delete all occurrences
+ fi
+ elif [[ $1 == *=* ]]; then
+ __dotenv+="${1#+}"$'\n'
+ fi
+ shift
+ done
+ [[ $__dotenv == "$saved" ]] || .env::file save
+}
+
+.env.puts() { echo "${1-}" >>"$__dotenv_file" && __dotenv+="$1"$'\n'; }
+
+.env.generate() {
+ .env::arg "key required for generate" "$@" || return
+ .env.get "$1" && return || REPLY=$("${@:2}") || return
+ .env::one "generate: ouptut of '${*:2}' has more than one line" "$REPLY" || return
+ .env.puts "$1=$REPLY"
+}
+
+.env.--file() {
+ .env::arg "filename required for --file" "$@" || return
+ __dotenv_file=$1
+ .env::file load || return
+ (($# < 2)) || .env "${@:2}"
+}
+
+.env::arg() { [[ "${2-}" ]] || {
+ echo "$__dotenv_cmd: $1" >&2
+ return 64
+}; }
+
+.env::one() { [[ "$2" != *$'\n'* ]] || .env::arg "$1"; }
+
+.env::file() {
+ local REPLY=$__dotenv_file
+ case "$1" in
+ load)
+ __dotenv=
+ ! [[ -f "$REPLY" ]] || __dotenv="$(<"$REPLY")"$'\n' || return
+ ;;
+ save)
+ if [[ -L "$REPLY" ]] && declare -F -- realpath.resolved >/dev/null; then
+ realpath.resolved "$REPLY"
+ fi
+ { [[ ! -f "$REPLY" ]] || cp -p "$REPLY" "$REPLY.bak"; } &&
+ printf %s "$__dotenv" >"$REPLY.bak" && mv "$REPLY.bak" "$REPLY"
+ ;;
+ esac
+}
+
+# Shortcut to convert semver version (x.yyy.zzz) into a comparable number.
+version-number() {
+ echo "$@" | awk -F. '{ printf("%03d%03d%03d\n", $1,$2,$3); }'
+}
+
+# Get the current release channel for AzuraCast
+get-release-channel() {
+ local AZURACAST_VERSION="latest"
+ if [[ -f .env ]]; then
+ .env --file .env get AZURACAST_VERSION
+ AZURACAST_VERSION="${REPLY:-latest}"
+ fi
+
+ echo "$AZURACAST_VERSION"
+}
+
+get-release-branch-name() {
+ if [[ $(get-release-channel) == "stable" ]]; then
+ echo "stable"
+ else
+ echo "main"
+ fi
+}
+
+# This is a general-purpose function to ask Yes/No questions in Bash, either
+# with or without a default answer. It keeps repeating the question until it
+# gets a valid answer.
+ask() {
+ # https://djm.me/ask
+ local prompt default reply
+
+ while true; do
+
+ if [[ "${2:-}" == "Y" ]]; then
+ prompt="Y/n"
+ default=Y
+ elif [[ "${2:-}" == "N" ]]; then
+ prompt="y/N"
+ default=N
+ else
+ prompt="y/n"
+ default=
+ fi
+
+ # Ask the question (not using "read -p" as it uses stderr not stdout)
+ echo -n "$1 [$prompt] "
+
+ read reply
+
+ # Default?
+ if [[ -z "$reply" ]]; then
+ reply=${default}
+ fi
+
+ # Check if the reply is valid
+ case "$reply" in
+ Y* | y*) return 0 ;;
+ N* | n*) return 1 ;;
+ esac
+
+ done
+}
+
+# Generate a prompt to set an environment file value.
+envfile-set() {
+ local VALUE INPUT
+
+ .env --file .env
+
+ .env get "$1"
+ VALUE=${REPLY:-$2}
+
+ echo -n "$3 [$VALUE]: "
+ read INPUT
+
+ VALUE=${INPUT:-$VALUE}
+
+ .env set "${1}=${VALUE}"
+}
+
+#
+# Configure the ports used by AzuraCast.
+#
+setup-ports() {
+ envfile-set "AZURACAST_HTTP_PORT" "80" "Port to use for HTTP connections"
+ envfile-set "AZURACAST_HTTPS_PORT" "443" "Port to use for HTTPS connections"
+ envfile-set "AZURACAST_SFTP_PORT" "2022" "Port to use for SFTP connections"
+}
+
+#
+# Configure release mode settings.
+#
+setup-release() {
+ if [[ ! -f .env ]]; then
+ curl -fsSL https://raw.githubusercontent.com/AzuraCast/AzuraCast/main/sample.env -o .env
+ fi
+
+ local OLD_RELEASE_CHANNEL
+ .env --file .env get AZURACAST_VERSION
+ OLD_RELEASE_CHANNEL="${REPLY:-latest}"
+
+ local AZURACAST_VERSION="${OLD_RELEASE_CHANNEL}"
+
+ if [[ ! -z "${1}" ]]; then
+ echo "Setting release channel to the specific value: ${1}"
+ AZURACAST_VERSION="${1}"
+ elif [[ $AZURACAST_VERSION == "latest" ]]; then
+ if ask "Your current release channel is 'Rolling Release'. Switch to 'Stable' release channel?" N; then
+ AZURACAST_VERSION="stable"
+ fi
+ elif [[ $AZURACAST_VERSION == "stable" ]]; then
+ if ask "Your current release channel is 'Stable'. Switch to 'Rolling Release' release channel?" N; then
+ AZURACAST_VERSION="latest"
+ fi
+ else
+ if ask "Your current release channel is locked to a stable release, version '${OLD_RELEASE_CHANNEL}'. Switch to the 'Stable' release channel?" N; then
+ AZURACAST_VERSION="stable"
+ fi
+ fi
+
+ .env --file .env set AZURACAST_VERSION=${AZURACAST_VERSION}
+
+ if [[ $AZURACAST_VERSION != $OLD_RELEASE_CHANNEL ]]; then
+ if ask "You should update the Docker Utility Script after changing release channels. Automatically update it now?" Y; then
+ update-self
+ fi
+ fi
+}
+
+check-install-requirements() {
+ local CURRENT_OS CURRENT_ARCH REQUIRED_COMMANDS SCRIPT_DIR
+
+ set -e
+
+ echo "Checking installation requirements for AzuraCast..."
+
+ CURRENT_OS=$(uname -s)
+ if [[ $CURRENT_OS == "Linux" ]]; then
+ echo -en "\e[32m[PASS]\e[0m Operating System: ${CURRENT_OS}\n"
+ elif [[ $CURRENT_OS == "Darwin" ]]; then
+ echo -en "\e[32m[PASS]\e[0m Operating System: ${CURRENT_OS}\n"
+ else
+ echo -en "\e[41m[FAIL]\e[0m Operating System: ${CURRENT_OS}\n"
+
+ echo " You are running an unsupported operating system."
+ echo " Automated AzuraCast installation is not currently supported on this"
+ echo " operating system."
+ exit 1
+ fi
+
+ CURRENT_ARCH=$(uname -m)
+ if [[ $CURRENT_ARCH == "x86_64" ]]; then
+ echo -en "\e[32m[PASS]\e[0m Architecture: ${CURRENT_ARCH}\n"
+ elif [[ $CURRENT_ARCH == "aarch64" ]]; then
+ echo -en "\e[32m[PASS]\e[0m Architecture: ${CURRENT_ARCH}\n"
+ else
+ echo -en "\e[41m[FAIL]\e[0m Architecture: ${CURRENT_ARCH}\n"
+
+ echo " You are running an unsupported processor architecture."
+ echo " Automated AzuraCast installation is not currently supported on this "
+ echo " operating system."
+ exit 1
+ fi
+
+ REQUIRED_COMMANDS=(curl awk)
+ for COMMAND in "${REQUIRED_COMMANDS[@]}" ; do
+ if [[ $(command -v "$COMMAND") ]]; then
+ echo -en "\e[32m[PASS]\e[0m Command Present: ${COMMAND}\n"
+ else
+ echo -en "\e[41m[FAIL]\e[0m Command Present: ${COMMAND}\n"
+
+ echo " ${COMMAND} does not appear to be installed."
+ echo " Install ${COMMAND} using your host's package manager,"
+ echo " then continue installing using this script."
+ exit 1
+ fi
+ done
+
+ if [[ $EUID -ne 0 ]]; then
+ if [[ $(command -v sudo) ]]; then
+ echo -en "\e[32m[PASS]\e[0m User Permissions\n"
+ else
+ echo -en "\e[41m[FAIL]\e[0m User Permissions\n"
+
+ echo " You are not currently the root user, and "
+ echo " 'sudo' does not appear to be installed."
+ echo " Install sudo using your host's package manager,"
+ echo " then continue installing using this script."
+ exit 1
+ fi
+ else
+ echo -en "\e[32m[PASS]\e[0m User Permissions\n"
+ fi
+
+ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
+ if [[ $SCRIPT_DIR == "/var/azuracast" ]]; then
+ echo -en "\e[32m[PASS]\e[0m Installation Directory\n"
+ else
+ echo -en "\e[93m[WARN]\e[0m Installation Directory\n"
+ echo " AzuraCast is not installed in /var/azuracast, as is recommended"
+ echo " for most installations. This will not prevent AzuraCast from"
+ echo " working, but you will need to update any instructions in our"
+ echo " documentation to reflect your current directory:"
+ echo " $SCRIPT_DIR"
+ fi
+
+ echo -en "\e[32m[PASS]\e[0m All requirements met!\n"
+
+ set +e
+}
+
+install-docker() {
+ set -e
+
+ CURRENT_OS=$(uname -s)
+ if [[ $CURRENT_OS != "Linux" ]]; then
+ echo "The automatic Docker installation can only take place on Linux."
+ echo "Install Docker Desktop for your operating system."
+ exit 1
+ fi
+
+ curl -fsSL get.docker.com -o get-docker.sh
+ sh get-docker.sh
+ rm get-docker.sh
+
+ if [[ $EUID -ne 0 ]]; then
+ sudo usermod -aG docker "$(whoami)"
+
+ echo "You must log out or restart to apply necessary Docker permissions changes."
+ echo "Restart, then continue installing using this script."
+ exit 1
+ fi
+
+ set +e
+}
+
+install-docker-compose() {
+ set -e
+ local DOCKER_COMPOSE_VERSION
+ echo "Installing Docker Compose..."
+
+ CURRENT_OS=$(uname -s)
+ if [[ $CURRENT_OS != "Linux" ]]; then
+ echo "The automatic Docker installation can only take place on Linux."
+ echo "Install Docker Desktop for your operating system."
+ exit 1
+ fi
+
+ DOCKER_COMPOSE_VERSION=$(version-number $(docker compose version --short || echo "0.0.0"))
+ if (( $DOCKER_COMPOSE_VERSION >= $(version-number "5.0.0") )); then
+ echo "Docker Compose is already installed and newer than version 5.0.0. No update needed."
+ set +e
+ else
+ curl -fsSL -o docker-compose https://github.com/docker/compose/releases/download/v5.0.0/docker-compose-linux-$(uname -m)
+
+ ARCHITECTURE=amd64
+ if [ "$(uname -m)" = "aarch64" ]; then
+ ARCHITECTURE=arm64
+ fi
+ curl -fsSL -o docker-compose-switch https://github.com/docker/compose-switch/releases/download/v1.0.5/docker-compose-linux-${ARCHITECTURE}
+
+ if [[ $EUID -ne 0 ]]; then
+ sudo chmod a+x ./docker-compose
+ sudo chmod a+x ./docker-compose-switch
+
+ sudo mv ./docker-compose /usr/libexec/docker/cli-plugins/docker-compose
+ sudo mv ./docker-compose-switch /usr/local/bin/docker-compose
+ else
+ chmod a+x ./docker-compose
+ chmod a+x ./docker-compose-switch
+
+ mv ./docker-compose /usr/libexec/docker/cli-plugins/docker-compose
+ mv ./docker-compose-switch /usr/local/bin/docker-compose
+ fi
+
+ echo "Docker Compose updated!"
+ set +e
+ fi
+}
+
+run-installer() {
+ local AZURACAST_RELEASE_BRANCH
+ AZURACAST_RELEASE_BRANCH=$(get-release-branch-name)
+
+ if [[ ! -f .env ]]; then
+ curl -fsSL https://raw.githubusercontent.com/AzuraCast/AzuraCast/$AZURACAST_RELEASE_BRANCH/sample.env -o .env
+ fi
+ if [[ ! -f azuracast.env ]]; then
+ curl -fsSL https://raw.githubusercontent.com/AzuraCast/AzuraCast/$AZURACAST_RELEASE_BRANCH/azuracast.sample.env -o azuracast.env
+ fi
+ if [[ ! -f docker-compose.yml ]]; then
+ curl -fsSL https://raw.githubusercontent.com/AzuraCast/AzuraCast/$AZURACAST_RELEASE_BRANCH/docker-compose.sample.yml -o docker-compose.yml
+ fi
+
+ touch docker-compose.new.yml
+
+ local dc_config_test=$(dc -f docker-compose.new.yml config 2>/dev/null)
+ if [ $? -ne 0 ]; then
+ if ask "Docker Compose needs to be updated to continue. Update to latest version?" Y; then
+ install-docker-compose
+ fi
+ fi
+
+ curl -fsSL https://raw.githubusercontent.com/AzuraCast/AzuraCast/$AZURACAST_RELEASE_BRANCH/docker-compose.installer.yml -o docker-compose.installer.yml
+
+ dc -p azuracast_installer -f docker-compose.installer.yml pull
+ dc -p azuracast_installer -f docker-compose.installer.yml run --rm installer install "$@"
+
+ rm docker-compose.installer.yml
+}
+
+#
+# Run the initial installer of Docker and AzuraCast.
+# Usage: ./docker.sh install
+#
+install() {
+ check-install-requirements
+
+ if [[ $PODMAN_MODE -ne 0 ]]; then
+ echo "Podman was detected and will be used instead of Docker..."
+
+ if [[ $(command -v podman-compose) ]]; then
+ echo "Podman-compose is installed!"
+ else
+ echo "Podman mode is active, but podman-compose is not found."
+ echo "Install it by following the instructions on this page:"
+ echo "https://github.com/containers/podman-compose"
+ exit 1
+ fi
+ else
+ if [[ $(command -v docker) && $(docker --version) ]]; then
+ echo "Docker is already installed! Continuing..."
+ else
+ if ask "Docker does not appear to be installed. Install Docker now?" Y; then
+ install-docker
+ fi
+ fi
+
+ if [[ $(docker compose version) ]]; then
+ echo "Docker Compose v2 is already installed. Continuing..."
+ else
+ if [[ $(command -v docker-compose) ]]; then
+ echo "Docker Compose is already installed. Continuing..."
+ else
+ if ask "Docker Compose does not appear to be installed. Install Docker Compose now?" Y; then
+ install-docker-compose
+ fi
+ fi
+ fi
+ fi
+
+ setup-release
+
+ run-installer "$@"
+
+ # Installer creates a file at docker-compose.new.yml; copy it to the main spot.
+ if [[ -s docker-compose.new.yml ]]; then
+ if [[ -f docker-compose.yml ]]; then
+ rm docker-compose.yml
+ fi
+
+ mv docker-compose.new.yml docker-compose.yml
+ fi
+
+ # If this script is running as a non-root user, set the PUID/PGID in the environment vars appropriately.
+ if [[ $EUID -ne 0 ]]; then
+ .env --file .env set AZURACAST_PUID="$(id -u)"
+ .env --file .env set AZURACAST_PGID="$(id -g)"
+ fi
+
+ if [[ $PODMAN_MODE -ne 0 ]]; then
+ .env --file .env set AZURACAST_PODMAN_MODE=true
+ fi
+
+ dc pull
+
+ dc run --rm web -- azuracast_install "$@"
+ dc up -d
+ exit
+}
+
+install-dev() {
+ if [[ $(command -v docker) && $(docker --version) ]]; then
+ echo "Docker is already installed! Continuing..."
+ else
+ if ask "Docker does not appear to be installed. Install Docker now?" Y; then
+ install-docker
+ fi
+ fi
+
+ if [[ $(docker compose version) ]]; then
+ echo "Docker Compose v2 is already installed. Continuing..."
+ else
+ if [[ $(command -v docker-compose) ]]; then
+ echo "Docker Compose is already installed. Continuing..."
+ else
+ if ask "Docker Compose does not appear to be installed. Install Docker Compose now?" Y; then
+ install-docker-compose
+ fi
+ fi
+ fi
+
+ if [[ ! -f docker-compose.yml ]]; then
+ cp docker-compose.sample.yml docker-compose.yml
+ fi
+ if [[ ! -f docker-compose.override.yml ]]; then
+ cp docker-compose.dev.yml docker-compose.override.yml
+ fi
+ if [[ ! -f .env ]]; then
+ cp dev.env .env
+ fi
+ if [[ ! -f azuracast.env ]]; then
+ cp azuracast.dev.env azuracast.env
+
+ echo "Customize azuracast.env file now before continuing. Re-run this command to continue installation."
+ exit
+ fi
+
+ # If this script is running as a non-root user, set the PUID/PGID in the environment vars appropriately.
+ if [[ $EUID -ne 0 ]]; then
+ .env --file .env set AZURACAST_PUID="$(id -u)"
+ .env --file .env set AZURACAST_PGID="$(id -g)"
+ fi
+
+ if [[ $PODMAN_MODE -ne 0 ]]; then
+ .env --file .env set AZURACAST_PODMAN_MODE=true
+ fi
+
+ dc build
+ dc run --rm web -- azuracast_dev_install "$@"
+ dc up -d
+ exit
+}
+
+#
+# Update the Docker images and codebase.
+# Usage: ./docker.sh update
+#
+update() {
+ echo "[NOTICE] Before you continue, please make sure you have a recent snapshot of your system and or backed it up."
+ if ask "Are you ready to continue with the update?" Y; then
+
+ # Check for a new Docker Utility Script.
+ local AZURACAST_RELEASE_BRANCH
+ AZURACAST_RELEASE_BRANCH=$(get-release-branch-name)
+
+ curl -fsSL https://raw.githubusercontent.com/AzuraCast/AzuraCast/$AZURACAST_RELEASE_BRANCH/docker.sh -o docker.new.sh
+
+ local UTILITY_FILES_MATCH
+ UTILITY_FILES_MATCH="$(
+ cmp --silent docker.sh docker.new.sh
+ echo $?
+ )"
+
+ local UPDATE_UTILITY=0
+ if [[ ${UTILITY_FILES_MATCH} -ne 0 ]]; then
+ if ask "The Docker Utility Script has changed since your version. Update to latest version?" Y; then
+ UPDATE_UTILITY=1
+ fi
+ fi
+
+ if [[ ${UPDATE_UTILITY} -ne 0 ]]; then
+ mv docker.new.sh docker.sh
+ chmod a+x docker.sh
+
+ echo "A new Docker Utility Script has been downloaded."
+ echo "Please re-run the update process to continue."
+ exit
+ else
+ rm docker.new.sh
+ fi
+
+ # Check Docker version.
+ if [[ $PODMAN_MODE -eq 0 ]]; then
+ DOCKER_VERSION=$(docker version -f "{{.Server.Version}}")
+ DOCKER_VERSION_MAJOR=$(echo "$DOCKER_VERSION"| cut -d'.' -f 1)
+
+ if [ "${DOCKER_VERSION_MAJOR}" -ge 20 ]; then
+ echo "Docker server (version ${DOCKER_VERSION}) meets minimum version requirements."
+ else
+ if ask "Docker is out of date on this server. Attempt automatic upgrade?" Y; then
+ install-docker
+ install-docker-compose
+ fi
+ fi
+ fi
+
+ run-installer --update "$@"
+
+ # Check for updated Docker Compose config.
+ local COMPOSE_FILES_MATCH
+
+ if [[ ! -s docker-compose.new.yml ]]; then
+ curl -fsSL https://raw.githubusercontent.com/AzuraCast/AzuraCast/$AZURACAST_RELEASE_BRANCH/docker-compose.sample.yml -o docker-compose.new.yml
+ fi
+
+ COMPOSE_FILES_MATCH="$(
+ cmp --silent docker-compose.yml docker-compose.new.yml
+ echo $?
+ )"
+
+ if [[ ${COMPOSE_FILES_MATCH} -ne 0 ]]; then
+ dc -f docker-compose.new.yml pull
+ dc down --timeout 60
+
+ cp docker-compose.yml docker-compose.backup.yml
+ mv docker-compose.new.yml docker-compose.yml
+ else
+ rm docker-compose.new.yml
+
+ dc pull
+ dc down --timeout 60
+ fi
+
+ dc run --rm web -- azuracast_update "$@"
+ dc up -d
+
+ if ask "Clean up all stopped Docker containers and images to save space?" Y; then
+ d system prune -f
+ fi
+
+ echo "Update complete!"
+ fi
+ exit
+}
+
+#
+# Update this Docker utility script.
+# Usage: ./docker.sh update-self
+#
+update-self() {
+ local AZURACAST_RELEASE_BRANCH
+ AZURACAST_RELEASE_BRANCH=$(get-release-branch-name)
+
+ curl -H 'Cache-Control: no-cache, no-store' -fsSL \
+ https://raw.githubusercontent.com/AzuraCast/AzuraCast/$AZURACAST_RELEASE_BRANCH/docker.sh?$(date +%s) \
+ -o docker.sh
+ chmod a+x docker.sh
+
+ echo "New Docker utility script downloaded."
+ echo "You can now re-run any previous command with the updated utility script."
+ exit
+}
+
+#
+# Run a CLI command inside the Docker container.
+# Usage: ./docker.sh cli [command]
+#
+cli() {
+ dc exec --user="azuracast" web azuracast_cli "$@"
+ exit
+}
+
+#
+# Enter the bash terminal of the running web container.
+# Usage: ./docker.sh bash
+#
+bash() {
+ dc exec --user="azuracast" web bash
+ exit
+}
+
+#
+# Enter the MariaDB database management terminal with the correct credentials.
+#
+db() {
+ dc exec web azuracast_db
+ exit
+}
+
+#
+# Back up the Docker volumes to a .tar.gz file.
+# Usage:
+# ./docker.sh backup [/custom/backup/dir/custombackupname.zip]
+#
+backup() {
+ local BACKUP_PATH BACKUP_DIR BACKUP_FILENAME BACKUP_EXT
+ BACKUP_PATH=$(readlink -f ${1:-"./backup.tar.gz"})
+ BACKUP_DIR=$(dirname -- "$BACKUP_PATH")
+ BACKUP_FILENAME=$(basename -- "$BACKUP_PATH")
+ BACKUP_EXT="${BACKUP_FILENAME##*.}"
+ shift
+
+ # Prepare permissions
+ if [[ $EUID -ne 0 ]]; then
+ .env --file .env set AZURACAST_PUID="$(id -u)"
+ .env --file .env set AZURACAST_PGID="$(id -g)"
+ fi
+
+ dc exec --user="azuracast" web azuracast_cli azuracast:backup "/var/azuracast/backups/${BACKUP_FILENAME}" "$@"
+
+ # Move from Docker volume to local filesystem
+ d run --rm -v "azuracast_backups:/backup_src" \
+ -v "$BACKUP_DIR:/backup_dest" \
+ busybox mv "/backup_src/${BACKUP_FILENAME}" "/backup_dest/${BACKUP_FILENAME}"
+
+ echo "Backup completed."
+ exit
+}
+
+#
+# Restore an AzuraCast backup into Docker.
+# Usage:
+# ./docker.sh restore [/custom/backup/dir/custombackupname.zip]
+#
+restore() {
+ if [[ ! -f .env ]] || [[ ! -f azuracast.env ]]; then
+ echo "AzuraCast hasn't been installed yet on this server."
+ echo "You should run './docker.sh install' first before restoring."
+ exit 1
+ fi
+
+ if ask "Restoring will remove any existing AzuraCast installation data, replacing it with your backup. Continue?" Y; then
+ if [[ $1 != "" ]]; then
+ local BACKUP_PATH BACKUP_DIR BACKUP_FILENAME BACKUP_EXT
+ BACKUP_PATH=$(readlink -f ${1:-"./backup.tar.gz"})
+ BACKUP_DIR=$(dirname -- "$BACKUP_PATH")
+ BACKUP_FILENAME=$(basename -- "$BACKUP_PATH")
+ BACKUP_EXT="${BACKUP_FILENAME##*.}"
+ shift
+
+ if [[ ! -f ${BACKUP_PATH} ]]; then
+ echo "File '${BACKUP_PATH}' does not exist. Nothing to restore."
+ exit 1
+ fi
+
+ dc down
+
+ # Remove most AzuraCast volumes but preserve some essential ones.
+ d volume rm -f $(d volume ls | grep 'azuracast' | grep -v 'station\|install' | awk 'NR>1 {print $2}')
+ d volume create azuracast_backups
+
+ # Move from local filesystem to Docker volume
+ d run --rm -v "$BACKUP_DIR:/backup_src" \
+ -v "azuracast_backups:/backup_dest" \
+ busybox mv "/backup_src/${BACKUP_FILENAME}" "/backup_dest/${BACKUP_FILENAME}"
+
+ # Prepare permissions
+ if [[ $EUID -ne 0 ]]; then
+ .env --file .env set AZURACAST_PUID="$(id -u)"
+ .env --file .env set AZURACAST_PGID="$(id -g)"
+ fi
+
+ dc run --rm web -- azuracast_restore "/var/azuracast/backups/${BACKUP_FILENAME}" "$@"
+
+ # Move file back from volume to local filesystem
+ d run --rm -v "azuracast_backups:/backup_src" \
+ -v "$BACKUP_DIR:/backup_dest" \
+ busybox mv "/backup_src/${BACKUP_FILENAME}" "/backup_dest/${BACKUP_FILENAME}"
+
+ dc down --timeout 30
+ dc up -d
+ else
+ dc down
+
+ # Remove most AzuraCast volumes but preserve some essential ones.
+ d volume rm -f $(d volume ls | grep 'azuracast' | grep -v 'station\|backups\|install' | awk 'NR>1 {print $2}')
+
+ dc run --rm web -- azuracast_restore "$@"
+
+ dc down --timeout 30
+ dc up -d
+ fi
+ fi
+ exit
+}
+
+#
+# Restore the Docker volumes from a legacy backup format .tar.gz file.
+# Usage:
+# ./docker.sh restore [/custom/backup/dir/custombackupname.tar.gz]
+#
+restore-legacy() {
+ local APP_BASE_DIR BACKUP_PATH BACKUP_DIR BACKUP_FILENAME
+
+ APP_BASE_DIR=$(pwd)
+
+ BACKUP_PATH=${1:-"./backup.tar.gz"}
+ BACKUP_DIR=$(cd "$(dirname "$BACKUP_PATH")" && pwd)
+ BACKUP_FILENAME=$(basename "$BACKUP_PATH")
+
+ cd "$APP_BASE_DIR" || exit
+
+ if [ -f "$BACKUP_PATH" ]; then
+ dc down --timeout 30
+
+ d volume rm azuracast_db_data azuracast_station_data
+ d volume create azuracast_db_data
+ d volume create azuracast_station_data
+
+ d run --rm -v "$BACKUP_DIR:/backup" \
+ -v azuracast_db_data:/azuracast/db \
+ -v azuracast_station_data:/azuracast/stations \
+ busybox tar zxvf "/backup/$BACKUP_FILENAME"
+
+ dc up -d
+ else
+ echo "File $BACKUP_PATH does not exist in this directory. Nothing to restore."
+ exit 1
+ fi
+
+ exit
+}
+
+#
+# Stop all Docker containers and remove related volumes.
+# Usage: ./docker.sh uninstall
+#
+uninstall() {
+ if ask "This operation is destructive and will wipe your existing Docker containers. Continue?" N; then
+
+ dc down -v
+ dc rm -f
+ d volume prune -f
+
+ echo "All AzuraCast Docker containers and volumes were removed."
+ echo "To remove *all* Docker containers and volumes, run:"
+ echo " docker stop \$(docker ps -a -q)"
+ echo " docker rm \$(docker ps -a -q)"
+ echo " docker volume prune -f"
+ echo ""
+ fi
+
+ exit
+}
+
+#
+# Roll back to a specific stable release version.
+#
+rollback() {
+ local AZURACAST_ROLLBACK_VERSION
+ AZURACAST_ROLLBACK_VERSION="$1"
+
+ if [[ -z "$AZURACAST_ROLLBACK_VERSION" ]]; then
+ echo "No version specified. Specify a version, like 0.19.0."
+ exit 1
+ fi
+
+ echo "[NOTICE] Before you continue, please make sure you have a recent snapshot of your system and or backed it up."
+ if ask "Are you ready to continue with the rollback?" Y; then
+ dc exec --user="azuracast" web azuracast_cli azuracast:setup:rollback "${AZURACAST_ROLLBACK_VERSION}"
+ dc down --timeout 60
+
+ .env --file .env set AZURACAST_VERSION=${AZURACAST_ROLLBACK_VERSION}
+
+ dc pull
+ dc run --rm web -- azuracast_update
+ dc up -d
+
+ if ask "Clean up all stopped Docker containers and images to save space?" Y; then
+ d system prune -f
+ fi
+
+ echo "Rollback complete. Your installation has been returned to stable version '${AZURACAST_ROLLBACK_VERSION}'."
+ echo "To return to the regular update channels, run:"
+ echo " ./docker.sh setup-release"
+ echo " "
+ fi
+ exit
+}
+
+#
+# LetsEncrypt: Now managed via the Web UI.
+#
+setup-letsencrypt() {
+ echo "LetsEncrypt is now managed from within the web interface."
+ echo "You can manage it via the 'Administration' panel, then 'System Settings'."
+ echo "Under 'Services' you will find the LetsEncrypt settings."
+}
+
+letsencrypt-create() {
+ setup-letsencrypt
+ exit
+}
+
+#
+# Utility script to facilitate switching ports.
+# Usage: ./docker.sh change-ports
+#
+change-ports() {
+ setup-ports
+
+ dc down --timeout 60
+ dc up -d
+}
+
+#
+# Helper scripts for basic Docker Compose functions
+#
+up() {
+ echo "Starting up AzuraCast services..."
+ dc up -d
+}
+
+down() {
+ echo "Shutting down AzuraCast services..."
+ dc down --timeout 60
+}
+
+restart() {
+ down
+ up
+}
+
+# Ensure we're in the same directory as this script.
+cd "$( dirname "${BASH_SOURCE[0]}" )" || exit
+
+# Podman support
+if [[ $(command -v podman) ]]; then
+ PODMAN_MODE=1
+fi
+
+"$@"
diff --git a/flood/README.md b/flood/README.md
new file mode 100644
index 0000000..00a0636
--- /dev/null
+++ b/flood/README.md
@@ -0,0 +1,20 @@
+# Flood
+
+Web UI for rTorrent.
+
+## Setup
+
+1. Start:
+ ```
+ docker compose up -d
+ ```
+
+## Access
+
+- Web UI: `http://<host>:9091`
+
+## What to Configure
+
+- **rTorrent socket** — The volume `- /home/tyler/rtorrent/.session:/home/tyler/rtorrent/.session` must point to your rTorrent session directory (where `rpc.socket` lives). Update the host path if your rTorrent setup differs.
+- **Downloads volume** — `- /srv/torrents:/srv/torrents` mounts your torrent download directory. Update this to match your setup.
+- **User UID/GID** — The `user: "1000:1000"` line should match your rTorrent user. Change it if rTorrent runs as a different user.
diff --git a/flood/docker-compose.yml b/flood/docker-compose.yml
new file mode 100644
index 0000000..59dbc38
--- /dev/null
+++ b/flood/docker-compose.yml
@@ -0,0 +1,17 @@
+services:
+ flood:
+ image: jesec/flood:latest
+ container_name: flood
+ user: "1000:1000"
+ command: --port 9091
+ ports:
+ - "9091:9091"
+ environment:
+ HOME: /config
+ volumes:
+ # Mount rtorrent session directory (contains rpc.socket)
+ - /home/tyler/rtorrent/.session:/home/tyler/rtorrent/.session
+ - ./flood-config:/config
+ # Mount RAID storage for downloads
+ - /srv/torrents:/srv/torrents
+ restart: unless-stopped
diff --git a/glance/.env.example b/glance/.env.example
new file mode 100644
index 0000000..6d1e7c4
--- /dev/null
+++ b/glance/.env.example
@@ -0,0 +1,3 @@
+# Variables defined here will be available to use anywhere in the config with the syntax ${MY_SECRET_TOKEN}
+# Note: making changes to this file requires re-running docker compose up
+MY_SECRET_TOKEN=your_secret_token_here
diff --git a/glance/README.md b/glance/README.md
new file mode 100644
index 0000000..e1c6168
--- /dev/null
+++ b/glance/README.md
@@ -0,0 +1,28 @@
+# Glance
+
+A highly customizable personal dashboard page.
+
+## Setup
+
+1. Copy `.env.example` to `.env` and set your secret token:
+ ```
+ cp .env.example .env
+ ```
+
+2. Place your `glance.yml` config file in `./config/`.
+
+3. Start:
+ ```
+ docker compose up -d
+ ```
+
+## Access
+
+- Dashboard: `http://<host>:7575`
+
+## What to Configure
+
+- **`.env`** — Set `MY_SECRET_TOKEN` to a random string. This is used in the Glance config to reference secrets (e.g. API tokens for widgets).
+- **Config** — You need a `glance.yml` in `./config/`. See the [Glance docs](https://github.com/glanceapp/glance) for the config format.
+- **Assets** — Static assets (images, CSS) go in `./assets/` and are served at `/assets/`.
+- **System mounts** — The compose file mounts `/srv` and `/home` as read-only. Remove or adjust these if your dashboard doesn't need filesystem access.
diff --git a/glance/docker-compose.yml b/glance/docker-compose.yml
new file mode 100644
index 0000000..51825a4
--- /dev/null
+++ b/glance/docker-compose.yml
@@ -0,0 +1,16 @@
+services:
+ glance:
+ container_name: glance
+ image: glanceapp/glance
+ restart: unless-stopped
+ volumes:
+ - ./config:/app/config
+ - ./assets:/app/assets
+ - /etc/localtime:/etc/localtime:ro
+ - /srv:/raid:ro
+ - /home:/root:ro
+ # Optionally, also mount docker socket if you want to use the docker containers widget
+ # - /var/run/docker.sock:/var/run/docker.sock:ro
+ ports:
+ - 7575:8080
+ env_file: .env
diff --git a/glances/README.md b/glances/README.md
new file mode 100644
index 0000000..bdef3f3
--- /dev/null
+++ b/glances/README.md
@@ -0,0 +1,21 @@
+# Glances
+
+Real-time system monitoring.
+
+## Setup
+
+1. Start:
+ ```
+ docker compose up -d
+ ```
+
+## Access
+
+- Web UI: `http://<host>:61208`
+
+## What to Configure
+
+- **`glances.conf`** — Configures which filesystems to hide from monitoring. Edit this to match your system.
+- **Time zone** — Change `TZ=America/Los_Angeles` to your timezone.
+- **Docker socket** — Mounts `/var/run/docker.sock` to show container stats. Remove if not needed.
+- **System mounts** — `/srv` and `/home` are mounted as read-only for disk usage stats. Adjust or remove as needed.
diff --git a/glances/docker-compose.yml b/glances/docker-compose.yml
new file mode 100644
index 0000000..545dcb1
--- /dev/null
+++ b/glances/docker-compose.yml
@@ -0,0 +1,16 @@
+
+services:
+ monitoring:
+ image: nicolargo/glances:latest
+ restart: unless-stopped
+ pid: host
+ ports:
+ - "61208:61208/tcp"
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ - ./glances.conf:/etc/glances/glances.conf
+ - /srv:/raid:ro
+ - /home:/root:ro
+ environment:
+ - "GLANCES_OPT=-w"
+ - "TZ=America/Los_Angeles"
diff --git a/glances/glances.conf b/glances/glances.conf
new file mode 100644
index 0000000..7dc36e1
--- /dev/null
+++ b/glances/glances.conf
@@ -0,0 +1,2 @@
+[fs]
+hide=loop.*,/dev/loop.*,/etc.*
diff --git a/grafana/README.md b/grafana/README.md
new file mode 100644
index 0000000..4e0a53d
--- /dev/null
+++ b/grafana/README.md
@@ -0,0 +1,23 @@
+# Grafana + Prometheus
+
+Metrics visualization dashboard with Prometheus as the data source.
+
+## Setup
+
+1. Edit `prometheus.yml` to add your scrape targets.
+
+2. Start:
+ ```
+ docker compose up -d
+ ```
+
+## Access
+
+- Grafana: `http://<host>:3000`
+ - Default login: `admin` / `admin` (change on first login)
+
+## What to Configure
+
+- **`prometheus.yml`** — Add your scrape targets (node_exporter, nginx_exporter, etc.) before starting. The file currently contains example targets.
+- **Grafana** — On first login, add Prometheus as a data source at `http://prometheus:9090`.
+- **Data persistence** — Both `prometheus_data` and `grafana_data` are Docker volumes. They persist across restarts and stack upgrades.
diff --git a/grafana/docker-compose.yml b/grafana/docker-compose.yml
new file mode 100644
index 0000000..f9be4fd
--- /dev/null
+++ b/grafana/docker-compose.yml
@@ -0,0 +1,21 @@
+services:
+ prometheus:
+ image: prom/prometheus
+ container_name: prometheus
+ restart: unless-stopped
+ volumes:
+ - ./prometheus.yml:/etc/prometheus/prometheus.yml
+ - prometheus_data:/prometheus
+
+ grafana:
+ image: grafana/grafana
+ container_name: grafana
+ restart: unless-stopped
+ ports:
+ - "3000:3000"
+ volumes:
+ - grafana_data:/var/lib/grafana
+
+volumes:
+ prometheus_data:
+ grafana_data:
diff --git a/grafana/prometheus.yml b/grafana/prometheus.yml
new file mode 100644
index 0000000..2198130
--- /dev/null
+++ b/grafana/prometheus.yml
@@ -0,0 +1,16 @@
+global:
+ scrape_interval: 15s
+
+scrape_configs:
+ # Add your own scrape targets. Examples:
+ # - job_name: node_exporter
+ # static_configs:
+ # - targets: ['<your-server-ip>:9100']
+ #
+ # - job_name: nginx_exporter
+ # static_configs:
+ # - targets: ['<your-server-ip>:9113']
+ #
+ # - job_name: pihole
+ # static_configs:
+ # - targets: ['<your-pihole-ip>:9617']
diff --git a/immich/.env.example b/immich/.env.example
new file mode 100644
index 0000000..a7e4255
--- /dev/null
+++ b/immich/.env.example
@@ -0,0 +1,24 @@
+# You can find documentation for all the supported env variables at https://docs.immich.app/install/environment-variables
+
+# The location where your uploaded files are stored
+UPLOAD_LOCATION=/srv/photos
+
+# The location where your database files are stored. Network shares are not supported for the database
+DB_DATA_LOCATION=./postgres
+
+# To set a timezone, uncomment the next line and change Etc/UTC to a TZ identifier from this list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List
+TZ=America/Los_Angeles
+
+# The Immich version to use. You can pin this to a specific version like "v2.1.0"
+IMMICH_VERSION=v2
+
+# Connection secret for postgres. You should change it to a random password
+# Please use only the characters `A-Za-z0-9`, without special characters or spaces
+DB_PASSWORD=your_db_password_here
+
+# The values below this line do not need to be changed
+###################################################################################
+DB_USERNAME=postgres
+DB_DATABASE_NAME=immich
+
+IMMICH_VERSION=v3
diff --git a/immich/README.md b/immich/README.md
new file mode 100644
index 0000000..788372a
--- /dev/null
+++ b/immich/README.md
@@ -0,0 +1,31 @@
+# Immich
+
+Self-hosted photo and video management solution.
+
+## Setup
+
+1. Copy `.env.example` to `.env`:
+ ```
+ cp .env.example .env
+ ```
+
+2. Edit `.env`:
+ - Set `DB_PASSWORD` to a secure random string.
+ - Set `UPLOAD_LOCATION` to where you want photos stored.
+ - Set `DB_DATA_LOCATION` to where you want the database stored (avoid network shares).
+ - Set `TZ` to your timezone.
+
+3. Start:
+ ```
+ docker compose up -d
+ ```
+
+## Access
+
+- Web UI: `http://<host>:2283`
+
+## What to Configure
+
+- **`.env`** — All primary configuration lives here. See the [Immich docs](https://docs.immich.app/install/environment-variables) for all options.
+- **Hardware acceleration** — Uncomment the `extends` sections in `docker-compose.yml` for transcoding/ML hardware acceleration.
+- **`backup.sh`** — Example borg backup script for database and photo backups. Edit the paths and remote host to match your setup before using.
diff --git a/immich/backup.sh b/immich/backup.sh
new file mode 100755
index 0000000..91770a9
--- /dev/null
+++ b/immich/backup.sh
@@ -0,0 +1,26 @@
+#!/bin/sh
+
+# Paths
+UPLOAD_LOCATION="/srv/photos"
+BACKUP_PATH="/path/to/local/immich-backup"
+REMOTE_HOST="user@remote-host"
+REMOTE_BACKUP_PATH="/path/to/remote/immich"
+
+
+### Local
+
+# Backup Immich database
+docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres > "$UPLOAD_LOCATION"/database-backup/immich-database.sql
+# For deduplicating backup programs such as Borg or Restic, compressing the content can increase backup size by making it harder to deduplicate. If you are using a different program or still prefer to compress, you can use the following command instead:
+# docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=<DB_USERNAME> | /usr/bin/gzip --rsyncable > "$UPLOAD_LOCATION"/database-backup/immich-database.sql.gz
+
+### Append to local Borg repository
+borg create "$BACKUP_PATH/immich-borg::{now}" "$UPLOAD_LOCATION" --exclude "$UPLOAD_LOCATION"/thumbs/ --exclude "$UPLOAD_LOCATION"/encoded-video/
+borg prune --keep-weekly=4 --keep-monthly=3 "$BACKUP_PATH"/immich-borg
+borg compact "$BACKUP_PATH"/immich-borg
+
+
+### Append to remote Borg repository
+borg create "$REMOTE_HOST:$REMOTE_BACKUP_PATH/immich-borg::{now}" "$UPLOAD_LOCATION" --exclude "$UPLOAD_LOCATION"/thumbs/ --exclude "$UPLOAD_LOCATION"/encoded-video/
+borg prune --keep-weekly=4 --keep-monthly=3 "$REMOTE_HOST:$REMOTE_BACKUP_PATH"/immich-borg
+borg compact "$REMOTE_HOST:$REMOTE_BACKUP_PATH"/immich-borg
diff --git a/immich/docker-compose.yml b/immich/docker-compose.yml
new file mode 100755
index 0000000..2dd1a7f
--- /dev/null
+++ b/immich/docker-compose.yml
@@ -0,0 +1,76 @@
+#
+# WARNING: To install Immich, follow our guide: https://docs.immich.app/install/docker-compose
+#
+# Make sure to use the docker-compose.yml of the current release:
+#
+# https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
+#
+# The compose file on main may not be compatible with the latest release.
+
+name: immich
+
+services:
+ immich-server:
+ container_name: immich_server
+ image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
+ # extends:
+ # file: hwaccel.transcoding.yml
+ # service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
+ volumes:
+ # Do not edit the next line. If you want to change the media storage location on your system, edit the value of UPLOAD_LOCATION in the .env file
+ - ${UPLOAD_LOCATION}:/data
+ - /etc/localtime:/etc/localtime:ro
+ env_file:
+ - .env
+ ports:
+ - '2283:2283'
+ depends_on:
+ - redis
+ - database
+ restart: always
+ healthcheck:
+ disable: false
+
+ redis:
+ container_name: immich_redis
+ image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9
+ healthcheck:
+ test: redis-cli ping || exit 1
+ restart: always
+
+ immich-machine-learning:
+ container_name: immich_machine_learning
+ # For hardware acceleration, add one of -[armnn, cuda, rocm, openvino, rknn] to the image tag.
+ # Example tag: ${IMMICH_VERSION:-release}-cuda
+ image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}
+ # extends: # uncomment this section for hardware acceleration - see https://docs.immich.app/features/ml-hardware-acceleration
+ # file: hwaccel.ml.yml
+ # service: cpu # set to one of [armnn, cuda, rocm, openvino, openvino-wsl, rknn] for accelerated inference - use the `-wsl` version for WSL2 where applicable
+ volumes:
+ - model-cache:/cache
+ env_file:
+ - .env
+ restart: always
+ healthcheck:
+ disable: false
+
+ database:
+ container_name: immich_postgres
+ image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
+ environment:
+ POSTGRES_PASSWORD: ${DB_PASSWORD}
+ POSTGRES_USER: ${DB_USERNAME}
+ POSTGRES_DB: ${DB_DATABASE_NAME}
+ POSTGRES_INITDB_ARGS: '--data-checksums'
+ # Uncomment the DB_STORAGE_TYPE: 'HDD' var if your database isn't stored on SSDs
+ # DB_STORAGE_TYPE: 'HDD'
+ volumes:
+ # Do not edit the next line. If you want to change the database storage location on your system, edit the value of DB_DATA_LOCATION in the .env file
+ - ${DB_DATA_LOCATION}:/var/lib/postgresql/data
+ shm_size: 128mb
+ restart: always
+ healthcheck:
+ disable: false
+
+volumes:
+ model-cache:
diff --git a/invidious/README.md b/invidious/README.md
new file mode 100644
index 0000000..fbfa29f
--- /dev/null
+++ b/invidious/README.md
@@ -0,0 +1,28 @@
+# Invidious
+
+Self-hosted YouTube frontend. No ads, no tracking.
+
+## Setup
+
+1. Generate required keys:
+ ```
+ docker compose run --rm invidious --generate-hmac-key
+ docker compose run --rm invidious --generate-api-key
+ ```
+
+2. Set the generated keys as environment variables and start:
+ ```
+ INVIDIOUS_HMAC_KEY=<generated> INVIDIOUS_COMPANION_KEY=<generated> docker compose up -d
+ ```
+
+ Or add them to an `.env` file (create one in this directory).
+
+## Access
+
+- Web UI: `http://<host>:3000`
+
+## What to Configure
+
+- **Keys** — `INVIDIOUS_HMAC_KEY` and `INVIDIOUS_COMPANION_KEY` must be set. Without them, Invidious will not work.
+- **Database password** — Defaults to `kemal`. Change via `POSTGRES_PASSWORD` env var for production.
+- **Database init** — `docker/init-invidious-db.sh` and `config/sql/` are used for first-time database setup. No changes needed unless you customize the schema.
diff --git a/invidious/config/sql/annotations.sql b/invidious/config/sql/annotations.sql
new file mode 100644
index 0000000..3705829
--- /dev/null
+++ b/invidious/config/sql/annotations.sql
@@ -0,0 +1,12 @@
+-- Table: public.annotations
+
+-- DROP TABLE public.annotations;
+
+CREATE TABLE IF NOT EXISTS public.annotations
+(
+ id text NOT NULL,
+ annotations xml,
+ CONSTRAINT annotations_id_key UNIQUE (id)
+);
+
+GRANT ALL ON TABLE public.annotations TO current_user;
diff --git a/invidious/config/sql/channel_videos.sql b/invidious/config/sql/channel_videos.sql
new file mode 100644
index 0000000..cd4e0ff
--- /dev/null
+++ b/invidious/config/sql/channel_videos.sql
@@ -0,0 +1,30 @@
+-- Table: public.channel_videos
+
+-- DROP TABLE public.channel_videos;
+
+CREATE TABLE IF NOT EXISTS public.channel_videos
+(
+ id text NOT NULL,
+ title text,
+ published timestamp with time zone,
+ updated timestamp with time zone,
+ ucid text,
+ author text,
+ length_seconds integer,
+ live_now boolean,
+ premiere_timestamp timestamp with time zone,
+ views bigint,
+ CONSTRAINT channel_videos_id_key UNIQUE (id)
+);
+
+GRANT ALL ON TABLE public.channel_videos TO current_user;
+
+-- Index: public.channel_videos_ucid_idx
+
+-- DROP INDEX public.channel_videos_ucid_idx;
+
+CREATE INDEX IF NOT EXISTS channel_videos_ucid_idx
+ ON public.channel_videos
+ USING btree
+ (ucid COLLATE pg_catalog."default");
+
diff --git a/invidious/config/sql/channels.sql b/invidious/config/sql/channels.sql
new file mode 100644
index 0000000..55772da
--- /dev/null
+++ b/invidious/config/sql/channels.sql
@@ -0,0 +1,25 @@
+-- Table: public.channels
+
+-- DROP TABLE public.channels;
+
+CREATE TABLE IF NOT EXISTS public.channels
+(
+ id text NOT NULL,
+ author text,
+ updated timestamp with time zone,
+ deleted boolean,
+ subscribed timestamp with time zone,
+ CONSTRAINT channels_id_key UNIQUE (id)
+);
+
+GRANT ALL ON TABLE public.channels TO current_user;
+
+-- Index: public.channels_id_idx
+
+-- DROP INDEX public.channels_id_idx;
+
+CREATE INDEX IF NOT EXISTS channels_id_idx
+ ON public.channels
+ USING btree
+ (id COLLATE pg_catalog."default");
+
diff --git a/invidious/config/sql/nonces.sql b/invidious/config/sql/nonces.sql
new file mode 100644
index 0000000..644ac32
--- /dev/null
+++ b/invidious/config/sql/nonces.sql
@@ -0,0 +1,22 @@
+-- Table: public.nonces
+
+-- DROP TABLE public.nonces;
+
+CREATE TABLE IF NOT EXISTS public.nonces
+(
+ nonce text,
+ expire timestamp with time zone,
+ CONSTRAINT nonces_id_key UNIQUE (nonce)
+);
+
+GRANT ALL ON TABLE public.nonces TO current_user;
+
+-- Index: public.nonces_nonce_idx
+
+-- DROP INDEX public.nonces_nonce_idx;
+
+CREATE INDEX IF NOT EXISTS nonces_nonce_idx
+ ON public.nonces
+ USING btree
+ (nonce COLLATE pg_catalog."default");
+
diff --git a/invidious/config/sql/playlist_videos.sql b/invidious/config/sql/playlist_videos.sql
new file mode 100644
index 0000000..4b48b46
--- /dev/null
+++ b/invidious/config/sql/playlist_videos.sql
@@ -0,0 +1,19 @@
+-- Table: public.playlist_videos
+
+-- DROP TABLE public.playlist_videos;
+
+CREATE TABLE IF NOT EXISTS public.playlist_videos
+(
+ title text,
+ id text,
+ author text,
+ ucid text,
+ length_seconds integer,
+ published timestamptz,
+ plid text references playlists(id),
+ index int8,
+ live_now boolean,
+ PRIMARY KEY (index,plid)
+);
+
+GRANT ALL ON TABLE public.playlist_videos TO current_user;
diff --git a/invidious/config/sql/playlists.sql b/invidious/config/sql/playlists.sql
new file mode 100644
index 0000000..83efce4
--- /dev/null
+++ b/invidious/config/sql/playlists.sql
@@ -0,0 +1,29 @@
+-- Type: public.privacy
+
+-- DROP TYPE public.privacy;
+
+CREATE TYPE public.privacy AS ENUM
+(
+ 'Public',
+ 'Unlisted',
+ 'Private'
+);
+
+-- Table: public.playlists
+
+-- DROP TABLE public.playlists;
+
+CREATE TABLE IF NOT EXISTS public.playlists
+(
+ title text,
+ id text primary key,
+ author text,
+ description text,
+ video_count integer,
+ created timestamptz,
+ updated timestamptz,
+ privacy privacy,
+ index int8[]
+);
+
+GRANT ALL ON public.playlists TO current_user;
diff --git a/invidious/config/sql/session_ids.sql b/invidious/config/sql/session_ids.sql
new file mode 100644
index 0000000..c493769
--- /dev/null
+++ b/invidious/config/sql/session_ids.sql
@@ -0,0 +1,23 @@
+-- Table: public.session_ids
+
+-- DROP TABLE public.session_ids;
+
+CREATE TABLE IF NOT EXISTS public.session_ids
+(
+ id text NOT NULL,
+ email text,
+ issued timestamp with time zone,
+ CONSTRAINT session_ids_pkey PRIMARY KEY (id)
+);
+
+GRANT ALL ON TABLE public.session_ids TO current_user;
+
+-- Index: public.session_ids_id_idx
+
+-- DROP INDEX public.session_ids_id_idx;
+
+CREATE INDEX IF NOT EXISTS session_ids_id_idx
+ ON public.session_ids
+ USING btree
+ (id COLLATE pg_catalog."default");
+
diff --git a/invidious/config/sql/users.sql b/invidious/config/sql/users.sql
new file mode 100644
index 0000000..ad002ec
--- /dev/null
+++ b/invidious/config/sql/users.sql
@@ -0,0 +1,29 @@
+-- Table: public.users
+
+-- DROP TABLE public.users;
+
+CREATE TABLE IF NOT EXISTS public.users
+(
+ updated timestamp with time zone,
+ notifications text[],
+ subscriptions text[],
+ email text NOT NULL,
+ preferences text,
+ password text,
+ token text,
+ watched text[],
+ feed_needs_update boolean,
+ CONSTRAINT users_email_key UNIQUE (email)
+);
+
+GRANT ALL ON TABLE public.users TO current_user;
+
+-- Index: public.email_unique_idx
+
+-- DROP INDEX public.email_unique_idx;
+
+CREATE UNIQUE INDEX IF NOT EXISTS email_unique_idx
+ ON public.users
+ USING btree
+ (lower(email) COLLATE pg_catalog."default");
+
diff --git a/invidious/config/sql/videos.sql b/invidious/config/sql/videos.sql
new file mode 100644
index 0000000..55da396
--- /dev/null
+++ b/invidious/config/sql/videos.sql
@@ -0,0 +1,23 @@
+-- Table: public.videos
+
+-- DROP TABLE public.videos;
+
+CREATE UNLOGGED TABLE IF NOT EXISTS public.videos
+(
+ id text NOT NULL,
+ info text,
+ updated timestamp with time zone,
+ CONSTRAINT videos_pkey PRIMARY KEY (id)
+);
+
+GRANT ALL ON TABLE public.videos TO current_user;
+
+-- Index: public.id_idx
+
+-- DROP INDEX public.id_idx;
+
+CREATE UNIQUE INDEX IF NOT EXISTS id_idx
+ ON public.videos
+ USING btree
+ (id COLLATE pg_catalog."default");
+
diff --git a/invidious/docker-compose.yml b/invidious/docker-compose.yml
new file mode 100644
index 0000000..c261a38
--- /dev/null
+++ b/invidious/docker-compose.yml
@@ -0,0 +1,81 @@
+services:
+ invidious:
+ image: quay.io/invidious/invidious:latest
+ restart: unless-stopped
+ # Remove "127.0.0.1:" if used from an external IP
+ ports:
+ - "3000:3000"
+ environment:
+ # Please read the following file for a comprehensive list of all available
+ # configuration options and their associated syntax:
+ # https://github.com/iv-org/invidious/blob/master/config/config.example.yml
+ INVIDIOUS_CONFIG: |
+ db:
+ dbname: invidious
+ user: kemal
+ password: ${POSTGRES_PASSWORD:-kemal}
+ host: invidious-db
+ port: 5432
+ check_tables: true
+ invidious_companion:
+ - private_url: "http://companion:8282/companion"
+ # Use the key generated in the 2nd step
+ invidious_companion_key: "${INVIDIOUS_COMPANION_KEY}"
+ # Use the key generated in the 1st step
+ hmac_key: "${INVIDIOUS_HMAC_KEY}"
+ healthcheck:
+ test: wget -nv --tries=1 --spider http://127.0.0.1:3000/api/v1/stats || exit 1
+ interval: 30s
+ timeout: 5s
+ retries: 2
+ logging:
+ options:
+ max-size: "1G"
+ max-file: "4"
+ depends_on:
+ - invidious-db
+
+ companion:
+ image: quay.io/invidious/invidious-companion:latest
+ # Please read the following file for a comprehensive list of all available
+ # environment variables and their associated syntax:
+ # https://github.com/iv-org/invidious/blob/master/config/config.example.yml
+ environment:
+ # Use the key generated in the 2nd step
+ - SERVER_SECRET_KEY=${INVIDIOUS_COMPANION_KEY}
+ restart: unless-stopped
+ # Uncomment only if you have configured "public_url" for Invidious companion
+ # Or if you want to use Invidious companion as an API in your program.
+ # Remove "127.0.0.1:" if used from an external IP
+ #ports:
+ # - "127.0.0.1:8282:8282"
+ logging:
+ options:
+ max-size: "1G"
+ max-file: "4"
+ cap_drop:
+ - ALL
+ read_only: true
+ # cache for youtube library
+ volumes:
+ - companioncache:/var/tmp/youtubei.js:rw
+ security_opt:
+ - no-new-privileges:true
+
+ invidious-db:
+ image: docker.io/library/postgres:14
+ restart: unless-stopped
+ volumes:
+ - postgresdata:/var/lib/postgresql/data
+ - ./config/sql:/config/sql
+ - ./docker/init-invidious-db.sh:/docker-entrypoint-initdb.d/init-invidious-db.sh
+ environment:
+ POSTGRES_DB: invidious
+ POSTGRES_USER: kemal
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kemal}
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
+
+volumes:
+ postgresdata:
+ companioncache:
diff --git a/invidious/docker/Dockerfile b/invidious/docker/Dockerfile
new file mode 100644
index 0000000..f837dae
--- /dev/null
+++ b/invidious/docker/Dockerfile
@@ -0,0 +1,81 @@
+# https://github.com/openssl/openssl/releases/tag/openssl-3.6.2
+ARG OPENSSL_VERSION='3.6.2'
+ARG OPENSSL_SHA256='aaf51a1fe064384f811daeaeb4ec4dce7340ec8bd893027eee676af31e83a04f'
+
+FROM 84codes/crystal:1.20.2-alpine AS dependabot-crystal
+
+# We compile openssl ourselves due to a memory leak in how crystal interacts
+# with openssl
+# Reference: https://github.com/iv-org/invidious/issues/1438#issuecomment-3087636228
+FROM dependabot-crystal AS openssl-builder
+RUN apk add --no-cache curl perl linux-headers
+
+WORKDIR /
+
+ARG OPENSSL_VERSION
+ARG OPENSSL_SHA256
+RUN curl -Ls "https://github.com/openssl/openssl/releases/download/openssl-${OPENSSL_VERSION}/openssl-${OPENSSL_VERSION}.tar.gz" --output openssl-${OPENSSL_VERSION}.tar.gz
+RUN echo "${OPENSSL_SHA256} openssl-${OPENSSL_VERSION}.tar.gz" | sha256sum -c
+RUN tar -xzvf openssl-${OPENSSL_VERSION}.tar.gz
+
+RUN cd openssl-${OPENSSL_VERSION} && ./Configure --openssldir=/etc/ssl && make -j$(nproc)
+
+FROM dependabot-crystal AS builder
+
+RUN apk add --no-cache sqlite-static yaml-static
+RUN apk del openssl-dev openssl-libs-static
+
+ARG release
+
+WORKDIR /invidious
+COPY ./shard.yml ./shard.yml
+COPY ./shard.lock ./shard.lock
+RUN shards install --production
+
+COPY ./src/ ./src/
+# TODO: .git folder is required for building – this is destructive.
+# See definition of CURRENT_BRANCH, CURRENT_COMMIT and CURRENT_VERSION.
+COPY ./.git/ ./.git/
+
+# Required for fetching player dependencies
+COPY ./scripts/ ./scripts/
+COPY ./assets/ ./assets/
+COPY ./videojs-dependencies.yml ./videojs-dependencies.yml
+
+RUN crystal spec --warnings all \
+ --link-flags "-lxml2 -llzma"
+
+ARG OPENSSL_VERSION
+COPY --from=openssl-builder /openssl-${OPENSSL_VERSION} /openssl-${OPENSSL_VERSION}
+
+RUN --mount=type=cache,target=/root/.cache/crystal if [[ "${release}" == 1 ]] ; then \
+ PKG_CONFIG_PATH=/openssl-${OPENSSL_VERSION} \
+ crystal build ./src/invidious.cr \
+ --release \
+ --static --warnings all \
+ --link-flags "-lxml2 -llzma"; \
+ else \
+ PKG_CONFIG_PATH=/openssl-${OPENSSL_VERSION} \
+ crystal build ./src/invidious.cr \
+ --static --warnings all \
+ --link-flags "-lxml2 -llzma"; \
+ fi
+
+FROM alpine:3.23
+RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata
+WORKDIR /invidious
+RUN addgroup -g 1000 -S invidious && \
+ adduser -u 1000 -S invidious -G invidious
+COPY --chown=invidious ./config/config.* ./config/
+RUN mv -n config/config.example.yml config/config.yml
+RUN sed -i 's/host: \(127.0.0.1\|localhost\)/host: invidious-db/' config/config.yml
+COPY ./config/sql/ ./config/sql/
+COPY ./locales/ ./locales/
+COPY --from=builder /invidious/assets ./assets/
+COPY --from=builder /invidious/invidious .
+RUN chmod o+rX -R ./assets ./config ./locales
+
+EXPOSE 3000
+USER invidious
+ENTRYPOINT ["/sbin/tini", "--"]
+CMD [ "/invidious/invidious" ]
diff --git a/invidious/docker/init-invidious-db.sh b/invidious/docker/init-invidious-db.sh
new file mode 100755
index 0000000..22b4cc5
--- /dev/null
+++ b/invidious/docker/init-invidious-db.sh
@@ -0,0 +1,12 @@
+#!/bin/bash
+set -eou pipefail
+
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/channels.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/videos.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/channel_videos.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/users.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/session_ids.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/nonces.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/annotations.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/playlists.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/playlist_videos.sql
diff --git a/jellyfin/.env.example b/jellyfin/.env.example
new file mode 100644
index 0000000..35c83a9
--- /dev/null
+++ b/jellyfin/.env.example
@@ -0,0 +1,2 @@
+SONARR_API_KEY=your_sonarr_api_key_here
+RADARR_API_KEY=your_radarr_api_key_here
diff --git a/jellyfin/README.md b/jellyfin/README.md
new file mode 100644
index 0000000..7f64c6d
--- /dev/null
+++ b/jellyfin/README.md
@@ -0,0 +1,59 @@
+# Jellyfin + *arr Stack
+
+Media server stack consisting of:
+- **Jellyfin** — Media server (movies, TV, music, books)
+- **Sonarr** — TV show management
+- **Radarr** — Movie management
+- **Prowlarr** — Indexer management
+- **FlareSolverr** — Cloudflare bypass for indexers
+- **Unpackerr** — Auto-extract downloaded archives
+- **Recyclarr** — Automatic TRaSH-Guide sync for Sonarr/Radarr quality profiles
+
+## Setup
+
+### 1. Create the media user
+
+Run the setup script to create a dedicated `media` user (UID 1001) and set correct permissions:
+```
+bash setup-media-user.sh
+```
+
+This script:
+- Creates a `media` user/group with UID/GID 1001
+- Fixes ownership on config directories
+- Sets group permissions on `/srv/` media directories
+
+### 2. Configure environment
+
+Copy `.env.example` to `.env` and set your API keys:
+```
+cp .env.example .env
+```
+
+Get your API keys from Sonarr (Settings > General) and Radarr (Settings > General).
+
+### 3. Start
+
+```
+docker compose up -d
+```
+
+## Access
+
+| Service | URL |
+|-------------|--------------------------|
+| Jellyfin | `http://<host>:5000` |
+| Sonarr | `http://<host>:8989` |
+| Radarr | `http://<host>:7878` |
+| Prowlarr | `http://<host>:9696` |
+| FlareSolverr | `http://<host>:8191` |
+
+## What to Configure
+
+- **User/group IDs** — All services run as UID 1001/GID 1001. Change `user:`, `PUID`, and `PGID` throughout if your media user differs.
+- **Time zone** — Replace `TZ=America/Los_Angeles` throughout with your timezone.
+- **Media volumes** — All services mount `/srv:/data`. Your media root should be `/srv/` with subdirectories like `vids/`, `mus/`, etc. Adjust the host path if your storage layout differs.
+- **Fonts** — Jellyfin mounts `/usr/local/share/fonts/` for custom fonts. Remove if not needed.
+- **Hardware transcoding** — Jellyfin has `/dev/dri` passed through for VAAPI. Remove or adjust for your GPU setup.
+- **API keys** — Set `SONARR_API_KEY` and `RADARR_API_KEY` in `.env` for Unpackerr. You also need to configure these keys inside Unpackerr's web UI on first run.
+- **Recyclarr** — Configure `recyclarr-config/recyclarr.yml` to sync TRaSH-Guide profiles. Runs daily at 4 AM.
diff --git a/jellyfin/docker-compose.yml b/jellyfin/docker-compose.yml
new file mode 100644
index 0000000..23785b7
--- /dev/null
+++ b/jellyfin/docker-compose.yml
@@ -0,0 +1,133 @@
+services:
+ jellyfin:
+ image: jellyfin/jellyfin
+ container_name: jellyfin
+ user: 1001:1001
+ ports:
+ - 5000:8096/tcp
+ - 7359:7359/udp
+ group_add:
+ - "44" # video (for /dev/dri/card0)
+ - "992" # render (for /dev/dri/renderD128)
+ devices:
+ - /dev/dri:/dev/dri
+ volumes:
+ - ./config:/config
+ - ./cache:/cache
+ - type: bind
+ source: /srv/
+ target: /media
+ read_only: true
+ - type: bind
+ source: /usr/local/share/fonts/
+ target: /usr/local/share/fonts/custom
+ read_only: true
+ restart: unless-stopped
+ environment:
+ - LIBVA_DRIVER_NAME=i965
+ extra_hosts:
+ - 'host.docker.internal:host-gateway'
+
+ sonarr:
+ image: linuxserver/sonarr
+ container_name: sonarr
+ environment:
+ - PUID=1001
+ - PGID=1001
+ - TZ=America/Los_Angeles # Change this to your timezone, e.g. America/Los_Angeles
+ - UMASK=002
+ ports:
+ - 8989:8989
+ volumes:
+ - ./sonarr-config:/config
+ - /srv:/data
+ restart: unless-stopped
+
+ radarr:
+ image: linuxserver/radarr
+ container_name: radarr
+ environment:
+ - PUID=1001
+ - PGID=1001
+ - TZ=America/Los_Angeles # Change this to your timezone, e.g. America/Los_Angeles
+ - UMASK=002
+ ports:
+ - 7878:7878
+ volumes:
+ - ./radarr-config:/config
+ - /srv:/data
+ restart: unless-stopped
+
+ prowlarr:
+ image: linuxserver/prowlarr
+ container_name: prowlarr
+ environment:
+ - PUID=1001
+ - PGID=1001
+ - TZ=America/Los_Angeles
+ - UMASK=002
+ ports:
+ - 9696:9696
+ volumes:
+ - ./prowlarr-config:/config
+ restart: unless-stopped
+
+ flaresolverr:
+ image: ghcr.io/flaresolverr/flaresolverr:latest
+ container_name: flaresolverr
+ environment:
+ - LOG_LEVEL=info
+ - TZ=America/Los_Angeles
+ ports:
+ - 8191:8191
+ restart: unless-stopped
+
+ unpackerr:
+ image: golift/unpackerr
+ container_name: unpackerr
+ user: 1000:1001
+ env_file:
+ - .env
+ environment:
+ - TZ=America/Los_Angeles
+ - UN_SONARR_0_URL=http://sonarr:8989
+ - UN_SONARR_0_API_KEY=${SONARR_API_KEY}
+ - UN_SONARR_0_PATHS_0=/data/torrents/downloads
+ - UN_SONARR_0_PROTOCOLS=torrent
+ - UN_RADARR_0_URL=http://radarr:7878
+ - UN_RADARR_0_API_KEY=${RADARR_API_KEY}
+ - UN_RADARR_0_PATHS_0=/data/torrents/downloads
+ - UN_RADARR_0_PROTOCOLS=torrent
+ - UN_CMDHOOK_0_COMMAND=chmod -R g+rw "{{ .Path }}"
+ - UN_CMDHOOK_0_EXTRACTED=true
+ - UN_CMDHOOK_0_SHELL=true
+ volumes:
+ - /srv:/data
+ restart: unless-stopped
+
+ recyclarr:
+ image: ghcr.io/recyclarr/recyclarr
+ container_name: recyclarr
+ user: 1001:1001
+ environment:
+ - TZ=America/Los_Angeles
+ - CRON_SCHEDULE=0 4 * * *
+ volumes:
+ - ./recyclarr-config:/config
+ restart: unless-stopped
+
+ # If you add a download client later, mount it the same way:
+ # qbittorrent:
+ # image: linuxserver/qbittorrent
+ # container_name: qbittorrent
+ # environment:
+ # - PUID=1001
+ # - PGID=1001
+ # - TZ=America/Los_Angeles
+ # - UMASK=002
+ # ports:
+ # - 8080:8080
+ # volumes:
+ # - ./qbittorrent-config:/config
+ # - /srv:/data
+ # restart: unless-stopped
diff --git a/jellyfin/setup-media-user.sh b/jellyfin/setup-media-user.sh
new file mode 100755
index 0000000..d470c16
--- /dev/null
+++ b/jellyfin/setup-media-user.sh
@@ -0,0 +1,75 @@
+#!/bin/bash
+set -e
+
+echo "=== Creating dedicated media user (UID 1001) and group (GID 1001) ==="
+
+# Create the media group
+sudo groupadd -g 1001 media
+
+# Create the media user (no home dir, no login shell)
+sudo useradd -u 1001 -g media -s /usr/sbin/nologin -M media
+
+# Add tyler to media group so tyler can access files created by the media user
+sudo usermod -aG media tyler
+
+# Add media user to video and render groups for hardware transcoding access
+sudo usermod -aG video,render media
+
+echo "=== Fixing Jellyfin config/cache ownership ==="
+sudo chown -R 1001:1001 /home/tyler/docker/jellyfin/config
+sudo chown -R 1001:1001 /home/tyler/docker/jellyfin/cache
+
+echo "=== Creating config directories for Sonarr and Radarr ==="
+sudo mkdir -p /home/tyler/docker/jellyfin/sonarr-config
+sudo mkdir -p /home/tyler/docker/jellyfin/radarr-config
+sudo chown -R 1001:1001 /home/tyler/docker/jellyfin/sonarr-config
+sudo chown -R 1001:1001 /home/tyler/docker/jellyfin/radarr-config
+
+echo "=== Setting media group ownership on /srv/ directories ==="
+# Change group to media on all media directories
+sudo chown -R :media /srv/vids /srv/mus /srv/books /srv/photos /srv/roms /srv/torrents
+
+echo "=== Clearing conflicting POSIX ACLs (if any) ==="
+# ACLs from NAS/Samba can override Unix group permissions.
+# Strip them so our chmod/chown rules actually take effect.
+if command -v setfacl &>/dev/null; then
+ sudo setfacl -R -b /srv/vids /srv/mus /srv/books /srv/photos /srv/roms /srv/torrents 2>/dev/null || true
+else
+ echo "Warning: setfacl not found. Install the 'acl' package if you have ACL issues."
+fi
+
+echo "=== Setting directory sticky bit (g+s) so new files inherit media group ==="
+sudo find /srv/vids /srv/mus /srv/books /srv/photos /srv/roms /srv/torrents -type d -exec chmod g+s {} +
+
+echo "=== Setting base permissions ==="
+# Group read+execute on all directories
+sudo find /srv/vids /srv/mus /srv/books /srv/photos /srv/roms /srv/torrents -type d -exec chmod g+rx {} +
+
+# Group read on all files
+sudo find /srv/vids /srv/mus /srv/books /srv/photos /srv/roms /srv/torrents -type f -exec chmod g+r {} +
+
+echo "=== Setting write permissions for Sonarr/Radarr managed directories ==="
+# Allow group write on top-level dirs and recursively within them
+sudo chmod g+w /srv/vids/movies /srv/vids/shows /srv/torrents
+sudo find /srv/vids/movies /srv/vids/shows /srv/torrents -type d -exec chmod g+w {} +
+sudo find /srv/vids/movies /srv/vids/shows /srv/torrents -type f -exec chmod g+w {} +
+
+echo "=== Done! ==="
+echo ""
+echo "You will need to log out and back in (or run 'newgrp media') for the"
+echo "group changes to take effect in your current shell."
+echo ""
+echo "Start the stack with: docker compose up -d"
+echo ""
+echo "=== Web UI Configuration ==="
+echo "Sonarr (http://<host>:8989):"
+echo " - Root folder: /data/vids/shows"
+echo " - Download client path: /data/torrents"
+echo ""
+echo "Radarr (http://<host>:7878):"
+echo " - Root folder: /data/vids/movies"
+echo " - Download client path: /data/torrents"
+echo ""
+echo "If you add a download client later, mount it with /srv:/data too"
+echo "and set its download directory to /data/torrents."
+echo "This ensures paths match and hard links / fast moves work correctly."
diff --git a/openwebui/.env.example b/openwebui/.env.example
new file mode 100644
index 0000000..7f95b8f
--- /dev/null
+++ b/openwebui/.env.example
@@ -0,0 +1 @@
+OPEN_TERMINAL_API_KEY=your_api_key_here
diff --git a/openwebui/README.md b/openwebui/README.md
new file mode 100644
index 0000000..dd8de37
--- /dev/null
+++ b/openwebui/README.md
@@ -0,0 +1,35 @@
+# Open WebUI
+
+Self-hosted web interface for interacting with LLMs (Ollama, OpenAI-compatible APIs, etc.).
+
+Includes:
+- **Open WebUI** — Main chat interface
+- **Open Terminal** — Browser-based terminal
+- **Apache Tika** — Document parsing for RAG
+
+## Setup
+
+1. Copy `.env.example` to `.env` and set your API key:
+ ```
+ cp .env.example .env
+ ```
+
+2. Start:
+ ```
+ docker compose up -d
+ ```
+
+## Access
+
+| Service | URL |
+|---------------|--------------------------|
+| Open WebUI | `http://<host>:3000` |
+| Open Terminal | `http://<host>:8001` |
+| Tika | `http://<host>:9998` |
+
+## What to Configure
+
+- **`.env`** — Set `OPEN_TERMINAL_API_KEY` to a secure random string.
+- **Ollama / LLM backend** — The `host.docker.internal` extra host is configured to reach a local Ollama instance. If your LLM backend runs elsewhere, update the host or add environment variables like `OPENAI_API_BASE_URL`.
+- **RAG settings** — `RAG_EMBEDDING_ENGINE=openai` uses OpenAI-compatible embeddings. Change if using a different engine.
+- **Persistent volumes** — `open-webui` and `open-terminal` are named Docker volumes. Data persists across container restarts.
diff --git a/openwebui/docker-compose.yml b/openwebui/docker-compose.yml
new file mode 100644
index 0000000..ff2832b
--- /dev/null
+++ b/openwebui/docker-compose.yml
@@ -0,0 +1,38 @@
+services:
+ openwebui:
+ image: ghcr.io/open-webui/open-webui:main
+ ports:
+ - "3000:8080"
+ volumes:
+ - open-webui:/app/backend/data
+ environment:
+ - ENABLE_KB_EXEC=true
+ - MODELS_CACHE_TTL=300
+ - ENABLE_REALTIME_CHAT_SAVE=False
+ - RAG_EMBEDDING_ENGINE=openai
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+ restart: unless-stopped
+
+ open-terminal:
+ image: ghcr.io/open-webui/open-terminal
+ container_name: open-terminal
+ ports:
+ - "8001:8000"
+ volumes:
+ - open-terminal:/home/user
+ environment:
+ - OPEN_TERMINAL_MULTI_USER=true
+ - OPEN_TERMINAL_API_KEY=${OPEN_TERMINAL_API_KEY}
+ restart: unless-stopped
+ tika:
+ image: apache/tika:latest-full
+ container_name: tika
+ ports:
+ - "9998:9998"
+ restart: unless-stopped
+
+
+volumes:
+ open-webui:
+ open-terminal:
diff --git a/watchtower/.env.example b/watchtower/.env.example
new file mode 100644
index 0000000..a5e5686
--- /dev/null
+++ b/watchtower/.env.example
@@ -0,0 +1,6 @@
+SMTP_FROM=watchtower@example.com
+SMTP_TO=you@example.com
+SMTP_SERVER=mail.example.com
+SMTP_PORT=587
+SMTP_USER=you@example.com
+SMTP_PASS=your_smtp_password_here
diff --git a/watchtower/README.md b/watchtower/README.md
new file mode 100644
index 0000000..1d3bc8b
--- /dev/null
+++ b/watchtower/README.md
@@ -0,0 +1,25 @@
+# Watchtower
+
+Automatically updates running Docker containers to the latest image versions.
+
+## Setup
+
+1. Copy `.env.example` to `.env`:
+ ```
+ cp .env.example .env
+ ```
+
+2. Edit `.env` with your SMTP credentials for email notifications.
+
+3. Start:
+ ```
+ docker compose up -d
+ ```
+
+## What to Configure
+
+- **`.env`** — All SMTP settings. Watchtower will send email notifications after updates (or if updates fail).
+- **Schedule** — `WATCHTOWER_SCHEDULE=0 0 4 * * 0` runs every Sunday at 4 AM. Uses 6-field cron. Change as desired.
+- **Docker socket** — Mounts `/var/run/docker.sock`. Required for Watchtower to manage containers.
+- **Cleanup** — `WATCHTOWER_CLEANUP=true` removes old images after updates. Set to `false` if you want to keep them.
+- **Scope labels** — To exclude a container from auto-updates, add `com.centurylinklabs.watchtower.scope=ignore` to its container labels.
diff --git a/watchtower/docker-compose.yml b/watchtower/docker-compose.yml
new file mode 100644
index 0000000..73a1c60
--- /dev/null
+++ b/watchtower/docker-compose.yml
@@ -0,0 +1,19 @@
+services:
+ watchtower:
+ image: nickfedor/watchtower
+ container_name: watchtower
+ restart: unless-stopped
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ environment:
+ - WATCHTOWER_SCHEDULE=0 0 4 * * 0 # Sundays 4am (6-field cron)
+ - WATCHTOWER_CLEANUP=true # remove old images after update
+ - WATCHTOWER_INCLUDE_RESTARTING=true
+ - WATCHTOWER_NOTIFICATIONS=email
+ - WATCHTOWER_NOTIFICATION_EMAIL_FROM=${SMTP_FROM}
+ - WATCHTOWER_NOTIFICATION_EMAIL_TO=${SMTP_TO}
+ - WATCHTOWER_NOTIFICATION_EMAIL_SERVER=${SMTP_SERVER}
+ - WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT=${SMTP_PORT}
+ - WATCHTOWER_NOTIFICATION_EMAIL_SERVER_USER=${SMTP_USER}
+ - WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD=${SMTP_PASS}
+ - WATCHTOWER_NOTIFICATION_EMAIL_USE_SSL_SMTP=true # for STARTTLS on 587