diff --git a/dockerized/.dockerignore b/dockerized/.dockerignore
new file mode 100644
index 0000000..59087b0
--- /dev/null
+++ b/dockerized/.dockerignore
@@ -0,0 +1,13 @@
+.env
+runtime/source/.env
+runtime/source/.git
+runtime/source/node_modules
+runtime/source/vendor
+runtime/source/public/build
+runtime/source/storage/app/private/recordings
+runtime/source/storage/framework/cache
+runtime/source/storage/framework/sessions
+runtime/source/storage/framework/testing
+runtime/source/storage/framework/views
+runtime/source/storage/logs
+
diff --git a/dockerized/.env.example b/dockerized/.env.example
new file mode 100644
index 0000000..9d20802
--- /dev/null
+++ b/dockerized/.env.example
@@ -0,0 +1,48 @@
+COMPOSE_PROJECT_NAME=nyone
+
+# Git source cloned by scripts/deploy.sh into runtime/source before Docker builds.
+GIT_REPOSITORY_URL=https://github.com/your-org/nyone.git
+GIT_REF=main
+
+# Public hostnames. TLS should terminate at your external proxy/CDN/load balancer.
+PUBLIC_SCHEME=https
+APP_DOMAIN=nyone.example.com
+HLS_DOMAIN=nyone-hls.example.com
+APP_HTTP_PORT=8080
+RTMP_PUBLIC_PORT=19935
+
+APP_NAME=Nyone
+APP_ENV=production
+APP_DEBUG=false
+APP_KEY=
+APP_PREVIOUS_KEYS=
+APP_LOCALE=en
+APP_FALLBACK_LOCALE=en
+APP_FAKER_LOCALE=en_US
+
+LOG_LEVEL=info
+MAIL_MAILER=log
+MAIL_FROM_ADDRESS=hello@example.com
+MAIL_FROM_NAME=Nyone
+
+POSTGRES_DB=nyone
+POSTGRES_USER=nyone
+POSTGRES_PASSWORD=
+
+MEDIAMTX_SHARED_SECRET=
+STREAMING_VIEWER_SYNC_INTERVAL=2
+STREAMING_VIEWER_COUNT_TTL=10
+STREAMING_VIEWER_IDENTITY_COOKIE=nyone_visitor_id
+STREAMING_VIEWER_IDENTITY_COOKIE_LIFETIME_DAYS=365
+STREAMING_VIEWER_IDENTITY_TTL=86400
+STREAMING_VIEWER_STATS_POLL_INTERVAL=30000
+STREAMING_VOD_RETENTION_DAYS=30
+
+# Optional deployment behavior.
+SEED_DATABASE=false
+APP_OPTIMIZE_ON_BOOT=true
+QUEUE_NAMES=default
+QUEUE_SLEEP=3
+QUEUE_TRIES=3
+QUEUE_MAX_TIME=3600
+QUEUE_TIMEOUT=90
diff --git a/dockerized/.gitignore b/dockerized/.gitignore
new file mode 100644
index 0000000..b6a6996
--- /dev/null
+++ b/dockerized/.gitignore
@@ -0,0 +1,3 @@
+.env
+runtime/
+
diff --git a/dockerized/docker-compose.yml b/dockerized/docker-compose.yml
new file mode 100644
index 0000000..c5515c0
--- /dev/null
+++ b/dockerized/docker-compose.yml
@@ -0,0 +1,188 @@
+name: ${COMPOSE_PROJECT_NAME:-nyone-service}
+
+x-app-environment: &app-environment
+ APP_NAME: ${APP_NAME:-Nyone}
+ APP_ENV: ${APP_ENV:-production}
+ APP_DEBUG: ${APP_DEBUG:-false}
+ APP_KEY: ${APP_KEY:-}
+ APP_PREVIOUS_KEYS: ${APP_PREVIOUS_KEYS:-}
+ APP_URL: ${PUBLIC_SCHEME:-https}://${APP_DOMAIN:-nyone.example.com}
+ APP_LOCALE: ${APP_LOCALE:-en}
+ APP_FALLBACK_LOCALE: ${APP_FALLBACK_LOCALE:-en}
+ APP_FAKER_LOCALE: ${APP_FAKER_LOCALE:-en_US}
+ APP_MAINTENANCE_DRIVER: file
+ BCRYPT_ROUNDS: 12
+ LOG_CHANNEL: stack
+ LOG_STACK: stderr
+ LOG_DEPRECATIONS_CHANNEL: "null"
+ LOG_LEVEL: ${LOG_LEVEL:-info}
+ DB_CONNECTION: pgsql
+ DB_HOST: db
+ DB_PORT: 5432
+ DB_DATABASE: ${POSTGRES_DB:-nyone}
+ DB_USERNAME: ${POSTGRES_USER:-nyone}
+ DB_PASSWORD: ${POSTGRES_PASSWORD:-}
+ SESSION_DRIVER: database
+ SESSION_LIFETIME: 120
+ SESSION_ENCRYPT: "false"
+ SESSION_PATH: /
+ SESSION_DOMAIN: ${SESSION_DOMAIN:-}
+ SESSION_SECURE_COOKIE: ${SESSION_SECURE_COOKIE:-true}
+ BROADCAST_CONNECTION: log
+ FILESYSTEM_DISK: local
+ QUEUE_CONNECTION: database
+ CACHE_STORE: redis
+ REDIS_CLIENT: phpredis
+ REDIS_HOST: redis
+ REDIS_PORT: 6379
+ MAIL_MAILER: ${MAIL_MAILER:-log}
+ MAIL_SCHEME: ${MAIL_SCHEME:-}
+ MAIL_HOST: ${MAIL_HOST:-127.0.0.1}
+ MAIL_PORT: ${MAIL_PORT:-2525}
+ MAIL_USERNAME: ${MAIL_USERNAME:-}
+ MAIL_PASSWORD: ${MAIL_PASSWORD:-}
+ MAIL_FROM_ADDRESS: ${MAIL_FROM_ADDRESS:-hello@example.com}
+ MAIL_FROM_NAME: ${MAIL_FROM_NAME:-Nyone}
+ VITE_APP_NAME: ${APP_NAME:-Nyone}
+ STREAMING_RTMP_INGEST_URL: rtmp://${APP_DOMAIN:-nyone.example.com}:${RTMP_PUBLIC_PORT:-19935}
+ STREAMING_HLS_PUBLIC_URL: ${PUBLIC_SCHEME:-https}://${HLS_DOMAIN:-nyone-hls.example.com}
+ STREAMING_MEDIAMTX_API_URL: http://mediamtx:9997
+ STREAMING_VIEWER_COUNT_CACHE_STORE: redis
+ STREAMING_VIEWER_COUNT_TTL: ${STREAMING_VIEWER_COUNT_TTL:-10}
+ STREAMING_VIEWER_SYNC_INTERVAL: ${STREAMING_VIEWER_SYNC_INTERVAL:-2}
+ STREAMING_VIEWER_IDENTITY_COOKIE: ${STREAMING_VIEWER_IDENTITY_COOKIE:-nyone_visitor_id}
+ STREAMING_VIEWER_IDENTITY_COOKIE_LIFETIME_DAYS: ${STREAMING_VIEWER_IDENTITY_COOKIE_LIFETIME_DAYS:-365}
+ STREAMING_VIEWER_IDENTITY_TTL: ${STREAMING_VIEWER_IDENTITY_TTL:-86400}
+ STREAMING_VIEWER_STATS_POLL_INTERVAL: ${STREAMING_VIEWER_STATS_POLL_INTERVAL:-30000}
+ STREAMING_VOD_RETENTION_DAYS: ${STREAMING_VOD_RETENTION_DAYS:-30}
+ STREAMING_RECORDINGS_PATH: /var/www/html/storage/app/private/recordings
+ MEDIAMTX_SHARED_SECRET: ${MEDIAMTX_SHARED_SECRET:-}
+ APP_DOMAIN: ${APP_DOMAIN:-nyone.example.com}
+ HLS_DOMAIN: ${HLS_DOMAIN:-nyone-hls.example.com}
+ HLS_INTERNAL_PORT: 19988
+ APP_OPTIMIZE_ON_BOOT: ${APP_OPTIMIZE_ON_BOOT:-true}
+ SEED_DATABASE: ${SEED_DATABASE:-false}
+
+x-app-service: &app-service
+ image: ${APP_IMAGE:-nyone-app:latest}
+ build:
+ context: .
+ dockerfile: docker/app/Dockerfile
+ restart: unless-stopped
+ environment:
+ <<: *app-environment
+ volumes:
+ - app-storage:/var/www/html/storage
+ depends_on:
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+
+services:
+ app:
+ <<: *app-service
+ environment:
+ <<: *app-environment
+ CONTAINER_ROLE: app
+ ports:
+ - "${APP_HTTP_PORT:-8080}:80"
+ healthcheck:
+ test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/up >/dev/null || exit 1"]
+ interval: 30s
+ timeout: 5s
+ retries: 5
+ start_period: 30s
+
+ migrate:
+ <<: *app-service
+ restart: "no"
+ environment:
+ <<: *app-environment
+ CONTAINER_ROLE: migrate
+ profiles:
+ - tools
+
+ queue:
+ <<: *app-service
+ environment:
+ <<: *app-environment
+ CONTAINER_ROLE: queue
+ QUEUE_NAMES: ${QUEUE_NAMES:-default}
+ QUEUE_SLEEP: ${QUEUE_SLEEP:-3}
+ QUEUE_TRIES: ${QUEUE_TRIES:-3}
+ QUEUE_MAX_TIME: ${QUEUE_MAX_TIME:-3600}
+ QUEUE_TIMEOUT: ${QUEUE_TIMEOUT:-90}
+
+ scheduler:
+ <<: *app-service
+ environment:
+ <<: *app-environment
+ CONTAINER_ROLE: scheduler
+
+ viewer-sync:
+ <<: *app-service
+ environment:
+ <<: *app-environment
+ CONTAINER_ROLE: viewer-sync
+ MEDIAMTX_HOST: mediamtx
+ MEDIAMTX_API_PORT: 9997
+ depends_on:
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ mediamtx:
+ condition: service_started
+
+ mediamtx:
+ image: ${MEDIAMTX_IMAGE:-nyone-mediamtx:latest}
+ build:
+ context: .
+ dockerfile: docker/mediamtx/Dockerfile
+ restart: unless-stopped
+ environment:
+ APP_URL: ${PUBLIC_SCHEME:-https}://${APP_DOMAIN:-nyone.example.com}
+ HLS_PUBLIC_URL: ${PUBLIC_SCHEME:-https}://${HLS_DOMAIN:-nyone-hls.example.com}
+ MEDIAMTX_SHARED_SECRET: ${MEDIAMTX_SHARED_SECRET:-}
+ RTMP_INTERNAL_PORT: 19935
+ HLS_INTERNAL_PORT: 19988
+ ports:
+ - "${RTMP_PUBLIC_PORT:-19935}:19935"
+ volumes:
+ - app-storage:/var/www/html/storage
+ depends_on:
+ app:
+ condition: service_started
+
+ db:
+ image: postgres:17-alpine
+ restart: unless-stopped
+ environment:
+ POSTGRES_DB: ${POSTGRES_DB:-nyone}
+ POSTGRES_USER: ${POSTGRES_USER:-nyone}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-}
+ volumes:
+ - postgres-data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+
+ redis:
+ image: redis:7-alpine
+ restart: unless-stopped
+ command: ["redis-server", "--appendonly", "yes"]
+ volumes:
+ - redis-data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+
+volumes:
+ app-storage:
+ postgres-data:
+ redis-data:
diff --git a/dockerized/docker/app/Dockerfile b/dockerized/docker/app/Dockerfile
new file mode 100644
index 0000000..5bc197f
--- /dev/null
+++ b/dockerized/docker/app/Dockerfile
@@ -0,0 +1,75 @@
+# syntax=docker/dockerfile:1
+
+FROM node:22-bookworm-slim AS assets
+
+WORKDIR /build
+
+COPY runtime/source/package.json runtime/source/package-lock.json ./
+RUN npm ci
+
+COPY runtime/source/ ./
+RUN npm run build
+
+FROM php:8.4-apache-bookworm AS app
+
+ENV APACHE_DOCUMENT_ROOT=/var/www/html/public \
+ COMPOSER_ALLOW_SUPERUSER=1
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ ca-certificates \
+ curl \
+ gettext-base \
+ git \
+ gosu \
+ libicu-dev \
+ libcurl4-openssl-dev \
+ libonig-dev \
+ libpq-dev \
+ libzip-dev \
+ netcat-openbsd \
+ unzip \
+ && docker-php-ext-install -j"$(nproc)" \
+ bcmath \
+ curl \
+ intl \
+ mbstring \
+ opcache \
+ pcntl \
+ pdo_pgsql \
+ pgsql \
+ zip \
+ && pecl install redis \
+ && docker-php-ext-enable redis \
+ && a2enmod headers proxy proxy_http rewrite setenvif \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
+
+WORKDIR /var/www/html
+
+COPY runtime/source/composer.json runtime/source/composer.lock ./
+RUN composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader --no-scripts
+
+COPY runtime/source/ ./
+COPY --from=assets /build/public/build ./public/build
+
+COPY docker/app/apache/nyone.conf.template /etc/apache2/sites-available/nyone.conf.template
+COPY docker/app/php/production.ini /usr/local/etc/php/conf.d/production.ini
+COPY docker/app/entrypoint.sh /usr/local/bin/nyone-entrypoint
+
+RUN composer dump-autoload --optimize --no-dev \
+ && php artisan package:discover --ansi \
+ && mkdir -p \
+ storage/app/private/recordings \
+ storage/app/public \
+ storage/framework/cache/data \
+ storage/framework/sessions \
+ storage/framework/views \
+ storage/logs \
+ bootstrap/cache \
+ && chown -R www-data:www-data storage bootstrap/cache \
+ && chmod +x /usr/local/bin/nyone-entrypoint
+
+ENTRYPOINT ["nyone-entrypoint"]
+CMD ["app"]
diff --git a/dockerized/docker/app/apache/nyone.conf.template b/dockerized/docker/app/apache/nyone.conf.template
new file mode 100644
index 0000000..40593a3
--- /dev/null
+++ b/dockerized/docker/app/apache/nyone.conf.template
@@ -0,0 +1,43 @@
+ServerName ${APP_DOMAIN}
+
+
+ ServerName ${APP_DOMAIN}
+
+ DocumentRoot /var/www/html/public
+ DirectoryIndex index.php
+
+ SetEnvIf X-Forwarded-Proto "^https$" HTTPS=on
+ SetEnvIf X-Forwarded-Ssl "^on$" HTTPS=on
+
+
+ Options -Indexes +FollowSymLinks
+ AllowOverride All
+ Require all granted
+
+
+
+ SetHandler application/x-httpd-php
+
+
+ ErrorLog /proc/self/fd/2
+ CustomLog /proc/self/fd/1 combined
+
+
+
+ ServerName ${HLS_DOMAIN}
+
+ ProxyRequests Off
+ ProxyPreserveHost On
+
+ ProxyPass "/" "http://mediamtx:${HLS_INTERNAL_PORT}/" retry=0
+ ProxyPassReverse "/" "http://mediamtx:${HLS_INTERNAL_PORT}/"
+
+ Header always set Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate"
+ Header always set Access-Control-Allow-Origin "${APP_URL}"
+ Header always set Access-Control-Allow-Methods "GET, HEAD, OPTIONS"
+ Header always set Access-Control-Allow-Headers "Range, Origin, Accept, Content-Type"
+
+ ErrorLog /proc/self/fd/2
+ CustomLog /proc/self/fd/1 combined
+
+
diff --git a/dockerized/docker/app/entrypoint.sh b/dockerized/docker/app/entrypoint.sh
new file mode 100644
index 0000000..1a5a364
--- /dev/null
+++ b/dockerized/docker/app/entrypoint.sh
@@ -0,0 +1,134 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd /var/www/html
+
+role="${CONTAINER_ROLE:-${1:-app}}"
+
+fail() {
+ echo "nyone-entrypoint: $*" >&2
+ exit 1
+}
+
+require_env() {
+ local key="$1"
+ local value="${!key:-}"
+
+ if [[ -z "$value" || "$value" == "change-me"* || "$value" == "base64:change-me"* ]]; then
+ fail "$key is required. Run dockerized/scripts/deploy.sh or set it in dockerized/.env."
+ fi
+}
+
+wait_for() {
+ local host="$1"
+ local port="$2"
+ local label="$3"
+ local attempts="${4:-60}"
+
+ for ((i = 1; i <= attempts; i++)); do
+ if nc -z "$host" "$port" >/dev/null 2>&1; then
+ return 0
+ fi
+
+ sleep 2
+ done
+
+ fail "Timed out waiting for $label at $host:$port."
+}
+
+artisan() {
+ gosu www-data php artisan "$@"
+}
+
+render_apache_config() {
+ export APP_DOMAIN HLS_DOMAIN HLS_INTERNAL_PORT APP_URL
+
+ envsubst '${APP_DOMAIN} ${HLS_DOMAIN} ${HLS_INTERNAL_PORT} ${APP_URL}' \
+ < /etc/apache2/sites-available/nyone.conf.template \
+ > /etc/apache2/sites-available/000-default.conf
+}
+
+prepare_laravel_paths() {
+ mkdir -p \
+ storage/app/private/recordings \
+ storage/app/public \
+ storage/framework/cache/data \
+ storage/framework/sessions \
+ storage/framework/views \
+ storage/logs \
+ bootstrap/cache
+
+ chown -R www-data:www-data storage bootstrap/cache
+}
+
+cache_laravel_bootstrap() {
+ if [[ "${APP_OPTIMIZE_ON_BOOT:-true}" != "true" ]]; then
+ return 0
+ fi
+
+ artisan config:clear
+ artisan route:clear
+ artisan view:clear
+ artisan event:clear
+ artisan config:cache
+ artisan route:cache
+ artisan view:cache
+}
+
+require_env APP_KEY
+require_env APP_DOMAIN
+require_env HLS_DOMAIN
+require_env MEDIAMTX_SHARED_SECRET
+require_env DB_DATABASE
+require_env DB_USERNAME
+require_env DB_PASSWORD
+
+if [[ "${WAIT_FOR_SERVICES:-true}" == "true" ]]; then
+ wait_for "${DB_HOST:-db}" "${DB_PORT:-5432}" "PostgreSQL"
+ wait_for "${REDIS_HOST:-redis}" "${REDIS_PORT:-6379}" "Redis"
+
+ if [[ "$role" == "viewer-sync" ]]; then
+ wait_for "${MEDIAMTX_HOST:-mediamtx}" "${MEDIAMTX_API_PORT:-9997}" "MediaMTX API"
+ fi
+fi
+
+prepare_laravel_paths
+artisan storage:link --force >/dev/null || true
+cache_laravel_bootstrap
+
+case "$role" in
+ app)
+ render_apache_config
+ exec apache2-foreground
+ ;;
+ migrate)
+ artisan migrate --force
+
+ if [[ "${SEED_DATABASE:-false}" == "true" ]]; then
+ artisan db:seed --force
+ fi
+ ;;
+ queue)
+ exec gosu www-data php artisan queue:work "${QUEUE_CONNECTION:-database}" \
+ --queue="${QUEUE_NAMES:-default}" \
+ --sleep="${QUEUE_SLEEP:-3}" \
+ --tries="${QUEUE_TRIES:-3}" \
+ --max-time="${QUEUE_MAX_TIME:-3600}" \
+ --timeout="${QUEUE_TIMEOUT:-90}"
+ ;;
+ scheduler)
+ exec gosu www-data php artisan schedule:work
+ ;;
+ viewer-sync)
+ exec gosu www-data php artisan streaming:sync-viewer-counts \
+ --isolated \
+ --interval="${STREAMING_VIEWER_SYNC_INTERVAL:-2}"
+ ;;
+ artisan)
+ shift || true
+ exec gosu www-data php artisan "$@"
+ ;;
+ *)
+ exec "$@"
+ ;;
+esac
diff --git a/dockerized/docker/app/php/production.ini b/dockerized/docker/app/php/production.ini
new file mode 100644
index 0000000..2358aec
--- /dev/null
+++ b/dockerized/docker/app/php/production.ini
@@ -0,0 +1,17 @@
+expose_php=Off
+memory_limit=512M
+upload_max_filesize=100M
+post_max_size=100M
+max_execution_time=120
+max_input_vars=3000
+variables_order=EGPCS
+
+opcache.enable=1
+opcache.enable_cli=1
+opcache.memory_consumption=256
+opcache.interned_strings_buffer=32
+opcache.max_accelerated_files=20000
+opcache.validate_timestamps=0
+opcache.save_comments=1
+opcache.jit=0
+
diff --git a/dockerized/docker/mediamtx/Dockerfile b/dockerized/docker/mediamtx/Dockerfile
new file mode 100644
index 0000000..9ea2dfd
--- /dev/null
+++ b/dockerized/docker/mediamtx/Dockerfile
@@ -0,0 +1,17 @@
+FROM bluenviron/mediamtx:latest-ffmpeg
+
+USER root
+
+RUN (apk add --no-cache curl gettext >/dev/null 2>&1) \
+ || (apt-get update \
+ && apt-get install -y --no-install-recommends ca-certificates curl gettext-base \
+ && rm -rf /var/lib/apt/lists/*)
+
+COPY docker/mediamtx/entrypoint.sh /usr/local/bin/nyone-mediamtx-entrypoint
+COPY docker/mediamtx/mediamtx.yml.template /etc/mediamtx/mediamtx.yml.template
+
+RUN chmod +x /usr/local/bin/nyone-mediamtx-entrypoint \
+ && mkdir -p /config /var/www/html/storage/app/private/recordings
+
+ENTRYPOINT ["nyone-mediamtx-entrypoint"]
+
diff --git a/dockerized/docker/mediamtx/entrypoint.sh b/dockerized/docker/mediamtx/entrypoint.sh
new file mode 100644
index 0000000..63c03b6
--- /dev/null
+++ b/dockerized/docker/mediamtx/entrypoint.sh
@@ -0,0 +1,35 @@
+#!/usr/bin/env sh
+set -eu
+
+fail() {
+ echo "nyone-mediamtx-entrypoint: $*" >&2
+ exit 1
+}
+
+if [ -z "${MEDIAMTX_SHARED_SECRET:-}" ] || [ "${MEDIAMTX_SHARED_SECRET#change-me}" != "$MEDIAMTX_SHARED_SECRET" ]; then
+ fail "MEDIAMTX_SHARED_SECRET is required. Run dockerized/scripts/deploy.sh or set it in dockerized/.env."
+fi
+
+: "${APP_URL:?APP_URL is required.}"
+: "${HLS_PUBLIC_URL:?HLS_PUBLIC_URL is required.}"
+: "${RTMP_INTERNAL_PORT:=19935}"
+: "${HLS_INTERNAL_PORT:=19988}"
+
+mkdir -p /config /var/www/html/storage/app/private/recordings
+
+export APP_URL HLS_PUBLIC_URL MEDIAMTX_SHARED_SECRET RTMP_INTERNAL_PORT HLS_INTERNAL_PORT
+
+envsubst '${APP_URL} ${HLS_PUBLIC_URL} ${MEDIAMTX_SHARED_SECRET} ${RTMP_INTERNAL_PORT} ${HLS_INTERNAL_PORT}' \
+ < /etc/mediamtx/mediamtx.yml.template \
+ > /config/mediamtx.yml
+
+if command -v mediamtx >/dev/null 2>&1; then
+ exec mediamtx /config/mediamtx.yml
+fi
+
+if [ -x /mediamtx ]; then
+ exec /mediamtx /config/mediamtx.yml
+fi
+
+fail "MediaMTX binary was not found in the base image."
+
diff --git a/dockerized/docker/mediamtx/mediamtx.yml.template b/dockerized/docker/mediamtx/mediamtx.yml.template
new file mode 100644
index 0000000..c936810
--- /dev/null
+++ b/dockerized/docker/mediamtx/mediamtx.yml.template
@@ -0,0 +1,35 @@
+authMethod: http
+authHTTPAddress: http://app/internal/mediamtx/auth?secret=${MEDIAMTX_SHARED_SECRET}
+authHTTPExclude:
+ - action: read
+ - action: playback
+ - action: api
+ - action: metrics
+ - action: pprof
+
+api: yes
+apiAddress: :9997
+
+rtmp: yes
+rtmpAddress: :${RTMP_INTERNAL_PORT}
+
+hls: yes
+hlsAddress: :${HLS_INTERNAL_PORT}
+hlsVariant: mpegts
+hlsAllowOrigins:
+ - ${APP_URL}
+ - ${HLS_PUBLIC_URL}
+
+pathDefaults:
+ record: yes
+ recordPath: /var/www/html/storage/app/private/recordings/%path/%Y-%m-%d_%H-%M-%S-%f
+
+ runOnReady: curl -fsS -X POST "http://app/internal/mediamtx/ready?secret=${MEDIAMTX_SHARED_SECRET}" --data-urlencode "path=$MTX_PATH" --data-urlencode "source_type=$MTX_SOURCE_TYPE" --data-urlencode "source_id=$MTX_SOURCE_ID"
+ runOnReadyRestart: no
+ runOnNotReady: curl -fsS -X POST "http://app/internal/mediamtx/not-ready?secret=${MEDIAMTX_SHARED_SECRET}" --data-urlencode "path=$MTX_PATH" --data-urlencode "source_type=$MTX_SOURCE_TYPE" --data-urlencode "source_id=$MTX_SOURCE_ID"
+ runOnRecordSegmentComplete: curl -fsS -X POST "http://app/internal/mediamtx/recording-complete?secret=${MEDIAMTX_SHARED_SECRET}" --data-urlencode "path=$MTX_PATH" --data-urlencode "segment_path=$MTX_SEGMENT_PATH" --data-urlencode "duration=$MTX_SEGMENT_DURATION"
+
+paths:
+ all_others:
+ source: publisher
+
diff --git a/dockerized/scripts/deploy.sh b/dockerized/scripts/deploy.sh
new file mode 100644
index 0000000..bfb67b1
--- /dev/null
+++ b/dockerized/scripts/deploy.sh
@@ -0,0 +1,184 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+ENV_FILE="$ROOT_DIR/.env"
+EXAMPLE_ENV_FILE="$ROOT_DIR/.env.example"
+SOURCE_DIR="$ROOT_DIR/runtime/source"
+COMPOSE_FILE="$ROOT_DIR/docker-compose.yml"
+
+info() {
+ printf '[nyone-deploy] %s\n' "$*"
+}
+
+fail() {
+ printf '[nyone-deploy] ERROR: %s\n' "$*" >&2
+ exit 1
+}
+
+require_command() {
+ command -v "$1" >/dev/null 2>&1 || fail "$1 is required."
+}
+
+env_value() {
+ local key="$1"
+
+ if [ ! -f "$ENV_FILE" ]; then
+ return 0
+ fi
+
+ awk -v key="$key" '
+ index($0, key "=") == 1 {
+ value = substr($0, length(key) + 2)
+ }
+ END {
+ print value
+ }
+ ' "$ENV_FILE"
+}
+
+set_env_value() {
+ local key="$1"
+ local value="$2"
+ local escaped
+
+ escaped="$(printf '%s' "$value" | sed 's/[\/&]/\\&/g')"
+
+ if grep -qE "^${key}=" "$ENV_FILE"; then
+ sed -i.bak "s/^${key}=.*/${key}=${escaped}/" "$ENV_FILE"
+ rm -f "$ENV_FILE.bak"
+ else
+ printf '\n%s=%s\n' "$key" "$value" >> "$ENV_FILE"
+ fi
+}
+
+is_missing_secret() {
+ local value="$1"
+
+ [ -z "$value" ] || [[ "$value" == "change-me"* ]] || [[ "$value" == "base64:change-me"* ]]
+}
+
+random_secret() {
+ openssl rand -base64 48 | tr -d '\n' | tr '/+' '_-' | tr -d '='
+}
+
+ensure_env_file() {
+ if [ -f "$ENV_FILE" ]; then
+ return 0
+ fi
+
+ [ -f "$EXAMPLE_ENV_FILE" ] || fail ".env.example was not found."
+ cp "$EXAMPLE_ENV_FILE" "$ENV_FILE"
+ info "Created .env from .env.example."
+}
+
+ensure_secret() {
+ local key="$1"
+ local value
+
+ value="$(env_value "$key")"
+
+ if is_missing_secret "$value"; then
+ set_env_value "$key" "$(random_secret)"
+ info "Generated $key."
+ fi
+}
+
+require_config_value() {
+ local key="$1"
+ local placeholder="${2:-}"
+ local value
+
+ value="$(env_value "$key")"
+
+ [ -n "$value" ] || fail "Set $key in $ENV_FILE."
+
+ if [ -n "$placeholder" ] && [ "$value" = "$placeholder" ]; then
+ fail "Replace the placeholder $key in $ENV_FILE."
+ fi
+}
+
+ensure_app_key() {
+ local value
+
+ value="$(env_value APP_KEY)"
+
+ if is_missing_secret "$value"; then
+ set_env_value APP_KEY "base64:$(openssl rand -base64 32 | tr -d '\n')"
+ info "Generated APP_KEY."
+ fi
+}
+
+clone_or_update_source() {
+ local repository_url="$1"
+ local git_ref="$2"
+
+ mkdir -p "$(dirname "$SOURCE_DIR")"
+
+ if [ -d "$SOURCE_DIR/.git" ]; then
+ info "Updating existing source checkout."
+ git -C "$SOURCE_DIR" fetch --prune --tags origin
+
+ if git -C "$SOURCE_DIR" rev-parse --verify --quiet "origin/$git_ref" >/dev/null; then
+ git -C "$SOURCE_DIR" checkout -B "$git_ref" "origin/$git_ref"
+ else
+ git -C "$SOURCE_DIR" checkout "$git_ref"
+ fi
+
+ return 0
+ fi
+
+ if [ -e "$SOURCE_DIR" ]; then
+ fail "$SOURCE_DIR exists but is not a Git checkout. Move it away or remove it before deploying."
+ fi
+
+ info "Cloning application repository."
+ git clone "$repository_url" "$SOURCE_DIR"
+ git -C "$SOURCE_DIR" checkout "$git_ref"
+}
+
+run_compose() {
+ docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"
+}
+
+require_command git
+require_command docker
+require_command openssl
+
+ensure_env_file
+ensure_app_key
+ensure_secret POSTGRES_PASSWORD
+ensure_secret MEDIAMTX_SHARED_SECRET
+
+GIT_REPOSITORY_URL="$(env_value GIT_REPOSITORY_URL)"
+GIT_REF="$(env_value GIT_REF)"
+
+require_config_value GIT_REPOSITORY_URL "https://github.com/your-org/nyone.git"
+require_config_value APP_DOMAIN "nyone.example.com"
+require_config_value HLS_DOMAIN "nyone-hls.example.com"
+
+if [ "$(env_value APP_DOMAIN)" = "$(env_value HLS_DOMAIN)" ]; then
+ fail "APP_DOMAIN and HLS_DOMAIN must be different because this deployment uses separate Apache virtual hosts."
+fi
+
+GIT_REF="${GIT_REF:-main}"
+
+clone_or_update_source "$GIT_REPOSITORY_URL" "$GIT_REF"
+
+info "Validating Compose configuration."
+run_compose config >/dev/null
+
+info "Building Docker images."
+run_compose build
+
+info "Starting database and Redis."
+run_compose up -d db redis
+
+info "Running migrations."
+run_compose --profile tools run --rm migrate
+
+info "Starting background workers."
+run_compose up -d --remove-orphans app mediamtx queue scheduler viewer-sync
+
+info "Current container status:"
+run_compose ps