Compare commits

..

3 commits

Author SHA1 Message Date
Maddox
0e3d0e3228 Phase 3: Migrate mixarr stack to replicant
- Add docker-compose.yml with MySQL, Redis, API, and Web services
- Add deploy-mixarr.yml Ansible playbook
- Services running on ports 3005 (API) and 3006 (Web)
- Migrated from alien to replicant VM

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 14:01:27 +00:00
Maddox
800166c2ac Fix download-stack healthchecks to test external connectivity
The localhost healthchecks didn't detect when gluetun's network
namespace died - services still responded locally but couldn't
reach the internet. Changed healthchecks to curl http://1.1.1.1
which requires the VPN tunnel to be working.

Also adds phpmyadmin, phppgadmin compose files and deploy playbook.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 12:39:36 +00:00
Maddox
394a973d6c Add healthchecks to download-stack, deploy playbook, and mealie resource limits
nzbget and rutorrent share gluetun's network namespace via
network_mode: service:gluetun. When autoheal restarts gluetun,
the dependent containers lose their network and fail silently.

Added healthchecks so autoheal detects and restarts them too:
- nzbget: curl localhost:6789
- rutorrent: curl localhost:80
- depends_on changed to condition: service_healthy

New deploy-download-stack.yml playbook stages startup (gluetun
first, wait for healthy, then dependents) since the LXC host
lacks Python requests for community.docker modules.

Also added resource limits to mealie (512M memory, 1 CPU).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 23:49:12 +00:00
8 changed files with 566 additions and 2 deletions

View file

@ -52,6 +52,11 @@ services:
labels:
- "com.centurylinklabs.watchtower.enable=true"
- "autoheal=true"
deploy:
resources:
limits:
memory: 512M
cpus: '1.0'
networks:
proxy:

View file

@ -0,0 +1,34 @@
services:
phpmyadmin:
image: phpmyadmin:latest
container_name: phpmyadmin
hostname: phpmyadmin
environment:
# Multiple MySQL hosts: Hetzner (im), NAS (different ports)
- PMA_HOSTS=192.168.12.3,192.168.1.251,192.168.1.251
- PMA_PORTS=3306,33306,3306
- MAX_EXECUTION_TIME=300
- MEMORY_LIMIT=512M
- UPLOAD_LIMIT=2048K
- TZ=America/Indiana/Indianapolis
ports:
- "2500:80"
restart: unless-stopped
networks:
- proxy
deploy:
resources:
limits:
memory: 512M
cpus: '1.0'
labels:
- "autoheal=true"
- "com.centurylinklabs.watchtower.enable=true"
- "homepage.group=Infrastructure"
- "homepage.name=Phpmyadmin"
- "homepage.icon=phpmyadmin.png"
- "homepage.href=https://php.3ddbrewery.com"
networks:
proxy:
external: true

View file

@ -0,0 +1,34 @@
services:
phppgadmin:
image: dockage/phppgadmin:latest
container_name: phppgadmin
hostname: phppgadmin
environment:
# PostgreSQL on Hetzner (im)
- PHP_PG_ADMIN_SERVER_HOST=192.168.12.2
- PHP_PG_ADMIN_SERVER_PORT=55432
- PHP_PG_ADMIN_SERVER_SSL_MODE=allow
ports:
- "5183:80"
- "4433:443"
restart: unless-stopped
volumes:
- ./data:/data
networks:
- proxy
deploy:
resources:
limits:
memory: 256M
cpus: '0.5'
labels:
- "autoheal=true"
- "com.centurylinklabs.watchtower.enable=true"
- "homepage.group=Infrastructure"
- "homepage.name=PhpPGadmin"
- "homepage.icon=postgres.png"
- "homepage.href=https://phppgadmin.3ddbrewery.com"
networks:
proxy:
external: true

View file

@ -64,6 +64,12 @@ services:
- /mnt/nas/downloads/nzbget:/downloads
- /mnt/nas/media:/media
network_mode: service:gluetun
healthcheck:
test: ["CMD-SHELL", "curl -sf --max-time 5 http://1.1.1.1 || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
deploy:
resources:
limits:
@ -72,7 +78,8 @@ services:
reservations:
memory: 256M
depends_on:
- gluetun
gluetun:
condition: service_healthy
restart: unless-stopped
labels:
- "autoheal=true"
@ -96,8 +103,15 @@ services:
- /mnt/nas/downloads/rutorrent:/downloads
- /mnt/nas/media:/media
network_mode: service:gluetun
healthcheck:
test: ["CMD-SHELL", "curl -sf --max-time 5 http://1.1.1.1 || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
depends_on:
- gluetun
gluetun:
condition: service_healthy
restart: unless-stopped
deploy:
resources:

View file

@ -0,0 +1,142 @@
services:
# =========================================================================
# MySQL Database
# =========================================================================
mysql:
image: mysql:8.0
container_name: mixarr_mysql
hostname: mysql
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
- MYSQL_DATABASE=${MYSQL_DATABASE}
- MYSQL_USER=${MYSQL_USER}
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
volumes:
- ./mysql_data:/var/lib/mysql
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
networks:
- mixarr_internal
deploy:
resources:
limits:
memory: 1G
cpus: '1.0'
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 5
labels:
- "autoheal=true"
- "com.centurylinklabs.watchtower.enable=true"
# =========================================================================
# Redis Cache
# =========================================================================
redis:
image: redis:7-alpine
container_name: mixarr_redis
hostname: redis
restart: unless-stopped
volumes:
- ./redis_data:/data
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
networks:
- mixarr_internal
deploy:
resources:
limits:
memory: 256M
cpus: '0.5'
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
labels:
- "autoheal=true"
- "com.centurylinklabs.watchtower.enable=true"
# =========================================================================
# Mixarr API
# =========================================================================
api:
image: ghcr.io/aquantumofdonuts/mixarr:latest
container_name: mixarr_api
hostname: api
restart: unless-stopped
ports:
- "3005:3005"
environment:
- DATABASE_URL=mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@mysql:3306/${MYSQL_DATABASE}
- REDIS_URL=redis://redis:6379
- SESSION_SECRET=${SESSION_SECRET}
- PORT=3005
- NODE_ENV=production
volumes:
- ./api_data:/data
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
command: >
sh -c "npx prisma migrate deploy && node apps/api/dist/index.js"
networks:
- mixarr_internal
- proxy
deploy:
resources:
limits:
memory: 512M
cpus: '1.0'
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3005/api/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 120s
labels:
- "autoheal=true"
- "com.centurylinklabs.watchtower.enable=true"
# =========================================================================
# Mixarr Web Frontend
# =========================================================================
web:
image: ghcr.io/aquantumofdonuts/mixarr:latest
container_name: mixarr_web
hostname: web
restart: unless-stopped
ports:
- "3006:3000"
environment:
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
- SESSION_SECRET=${SESSION_SECRET}
- NODE_ENV=production
volumes:
- ./web_data:/data
depends_on:
- api
networks:
- mixarr_internal
- proxy
deploy:
resources:
limits:
memory: 512M
cpus: '1.0'
labels:
- "autoheal=true"
- "com.centurylinklabs.watchtower.enable=true"
- "homepage.group=Media"
- "homepage.name=Mixarr"
- "homepage.icon=lidarr.png"
- "homepage.href=https://mixarr.3ddbrewery.com"
networks:
mixarr_internal:
driver: bridge
proxy:
external: true

View file

@ -0,0 +1,104 @@
---
# Deploy Database Admin Tools to databases VM
# Deploys: phpmyadmin, phppgadmin
# Target: databases (192.168.1.81)
- name: Deploy Database Admin Tools
hosts: databases
vars:
appdata_path: /home/docker/appdata
compose_src: "{{ playbook_dir }}/../compose-files/databases"
tasks:
# =========================================================================
# PHPMYADMIN
# =========================================================================
- name: Create phpmyadmin directory
ansible.builtin.file:
path: "{{ appdata_path }}/phpmyadmin"
state: directory
mode: '0755'
- name: Copy phpmyadmin docker-compose.yml
ansible.builtin.copy:
src: "{{ compose_src }}/phpmyadmin/docker-compose.yml"
dest: "{{ appdata_path }}/phpmyadmin/docker-compose.yml"
mode: '0644'
- name: Deploy phpmyadmin container
community.docker.docker_compose_v2:
project_src: "{{ appdata_path }}/phpmyadmin"
state: present
pull: always
register: phpmyadmin_result
- name: Show phpmyadmin status
ansible.builtin.debug:
msg: "phpMyAdmin deployed: {{ phpmyadmin_result.changed }}"
# =========================================================================
# PHPPGADMIN
# =========================================================================
- name: Create phppgadmin directory
ansible.builtin.file:
path: "{{ appdata_path }}/phppgadmin"
state: directory
mode: '0755'
- name: Create phppgadmin data directory
ansible.builtin.file:
path: "{{ appdata_path }}/phppgadmin/data"
state: directory
mode: '0755'
- name: Create phppgadmin logs directory
ansible.builtin.file:
path: "{{ appdata_path }}/phppgadmin/logs"
state: directory
mode: '0755'
- name: Copy phppgadmin docker-compose.yml
ansible.builtin.copy:
src: "{{ compose_src }}/phppgadmin/docker-compose.yml"
dest: "{{ appdata_path }}/phppgadmin/docker-compose.yml"
mode: '0644'
- name: Deploy phppgadmin container
community.docker.docker_compose_v2:
project_src: "{{ appdata_path }}/phppgadmin"
state: present
pull: always
register: phppgadmin_result
- name: Show phppgadmin status
ansible.builtin.debug:
msg: "phpPgAdmin deployed: {{ phppgadmin_result.changed }}"
# =========================================================================
# VERIFICATION
# =========================================================================
- name: Wait for phpmyadmin to be ready
ansible.builtin.uri:
url: "http://localhost:2500"
status_code: 200
timeout: 5
register: pma_health
retries: 10
delay: 5
until: pma_health.status == 200
- name: Wait for phppgadmin to be ready
ansible.builtin.uri:
url: "http://localhost:5183"
status_code: [200, 302]
timeout: 5
register: pga_health
retries: 10
delay: 5
until: pga_health.status in [200, 302]
- name: Summary
ansible.builtin.debug:
msg:
- "✅ phpMyAdmin: http://192.168.1.81:2500"
- "✅ phpPgAdmin: http://192.168.1.81:5183"

View file

@ -0,0 +1,71 @@
---
- name: Deploy Download Stack (gluetun + nzbget + rutorrent)
hosts: download-stack
become: true
vars:
service_dir: /home/docker/appdata/download-stack
compose_src: "{{ playbook_dir }}/../compose-files/download-stack/download-stack"
tasks:
- name: Create download-stack directory
file:
path: "{{ service_dir }}"
state: directory
mode: '0755'
- name: Ensure download network exists
shell: docker network inspect download >/dev/null 2>&1 || docker network create download
changed_when: false
- name: Copy docker-compose.yml
copy:
src: "{{ compose_src }}/docker-compose.yml"
dest: "{{ service_dir }}/docker-compose.yml"
mode: '0644'
backup: yes
- name: Check if local .env exists
delegate_to: localhost
become: no
stat:
path: "{{ compose_src }}/.env"
register: local_env
- name: Copy .env file (if local copy exists)
copy:
src: "{{ compose_src }}/.env"
dest: "{{ service_dir }}/.env"
mode: '0600'
when: local_env.stat.exists
- name: Pull images
shell: docker compose pull
args:
chdir: "{{ service_dir }}"
- name: Start gluetun first
shell: docker compose up -d --force-recreate gluetun
args:
chdir: "{{ service_dir }}"
- name: Wait for gluetun to become healthy
shell: docker inspect --format '{{ '{{' }}.State.Health.Status{{ '}}' }}' gluetun
register: gluetun_health
until: gluetun_health.stdout == "healthy"
retries: 30
delay: 5
changed_when: false
- name: Start nzbget and rutorrent
shell: docker compose up -d --force-recreate nzbget rutorrent
args:
chdir: "{{ service_dir }}"
- name: Check container status
shell: docker ps --filter name=gluetun --filter name=nzbget --filter name=rutorrent --format "table {{ '{{' }}.Names{{ '}}' }}\t{{ '{{' }}.Status{{ '}}' }}" | head -10
register: container_status
changed_when: false
- name: Show status
debug:
msg: "{{ container_status.stdout_lines }}"

160
playbooks/deploy-mixarr.yml Normal file
View file

@ -0,0 +1,160 @@
---
# Deploy Mixarr Stack to replicant VM
# Containers: mysql, redis, api, web
# Target: replicant (192.168.1.80)
- name: Deploy Mixarr Stack
hosts: replicant
vars:
appdata_path: /home/maddox/docker/appdata
service_name: mixarr
service_dir: "{{ appdata_path }}/{{ service_name }}"
compose_src: "{{ playbook_dir }}/../compose-files/replicant/{{ service_name }}"
tasks:
# =========================================================================
# PRE-FLIGHT CHECKS
# =========================================================================
- name: Check if .env file exists on control server
delegate_to: localhost
ansible.builtin.stat:
path: "{{ compose_src }}/.env"
register: env_file
- name: Fail if .env is missing
ansible.builtin.fail:
msg: |
.env file not found at {{ compose_src }}/.env
Copy .env.example to .env and fill in the secrets!
when: not env_file.stat.exists
# =========================================================================
# DIRECTORY SETUP
# =========================================================================
- name: Create mixarr base directory
ansible.builtin.file:
path: "{{ service_dir }}"
state: directory
owner: maddox
group: maddox
mode: '0755'
- name: Create mysql_data directory
ansible.builtin.file:
path: "{{ service_dir }}/mysql_data"
state: directory
owner: maddox
group: maddox
mode: '0755'
- name: Create redis_data directory
ansible.builtin.file:
path: "{{ service_dir }}/redis_data"
state: directory
owner: maddox
group: maddox
mode: '0755'
- name: Create api_data directory
ansible.builtin.file:
path: "{{ service_dir }}/api_data"
state: directory
owner: maddox
group: maddox
mode: '0755'
- name: Create web_data directory
ansible.builtin.file:
path: "{{ service_dir }}/web_data"
state: directory
owner: maddox
group: maddox
mode: '0755'
# =========================================================================
# FILE DEPLOYMENT
# =========================================================================
- name: Copy docker-compose.yml
ansible.builtin.copy:
src: "{{ compose_src }}/docker-compose.yml"
dest: "{{ service_dir }}/docker-compose.yml"
owner: maddox
group: maddox
mode: '0644'
- name: Copy .env file
ansible.builtin.copy:
src: "{{ compose_src }}/.env"
dest: "{{ service_dir }}/.env"
owner: maddox
group: maddox
mode: '0600'
# =========================================================================
# CONTAINER DEPLOYMENT
# =========================================================================
- name: Deploy mixarr stack
community.docker.docker_compose_v2:
project_src: "{{ service_dir }}"
state: present
pull: always
register: mixarr_result
- name: Show deployment status
ansible.builtin.debug:
msg: "Mixarr stack deployed: {{ mixarr_result.changed }}"
# =========================================================================
# VERIFICATION
# =========================================================================
- name: Wait for MySQL to be ready
ansible.builtin.command:
cmd: docker exec mixarr_mysql mysqladmin ping -h localhost
register: mysql_check
retries: 30
delay: 5
until: mysql_check.rc == 0
changed_when: false
- name: Wait for Redis to be ready
ansible.builtin.command:
cmd: docker exec mixarr_redis redis-cli ping
register: redis_check
retries: 10
delay: 3
until: "'PONG' in redis_check.stdout"
changed_when: false
- name: Wait for API to be healthy (may take up to 2 minutes for Prisma migrations)
ansible.builtin.uri:
url: "http://localhost:3005/api/health"
status_code: 200
timeout: 10
register: api_health
retries: 30
delay: 10
until: api_health.status == 200
ignore_errors: true
- name: Wait for Web frontend to be ready
ansible.builtin.uri:
url: "http://localhost:3006"
status_code: [200, 302]
timeout: 10
register: web_health
retries: 15
delay: 5
until: web_health.status in [200, 302]
ignore_errors: true
- name: Summary
ansible.builtin.debug:
msg:
- "========================================="
- "Mixarr Stack Deployment Complete"
- "========================================="
- "✅ MySQL: Running on internal network"
- "✅ Redis: Running on internal network"
- "✅ API: http://192.168.1.80:3005"
- "✅ Web: http://192.168.1.80:3006"
- "========================================="