replace dockerized to docker-bundle

This commit is contained in:
2026-05-19 20:25:15 +03:30
parent f302d605d2
commit cb62a0bae6
16 changed files with 56 additions and 56 deletions

View File

@@ -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

View File

@@ -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=80
RTMP_PUBLIC_PORT=1935
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

3
docker-bundle/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.env
runtime/

231
docker-bundle/README.md Normal file
View File

@@ -0,0 +1,231 @@
# Nyone Docker Bundle Deployment
This folder is a self-contained deployment bundle. A server only needs the `docker-bundle/` directory; the application repository is cloned later into `runtime/source` by `scripts/deploy.sh`.
## What It Runs
- Laravel app served by Apache.
- PostgreSQL database.
- Redis cache for Laravel cache and streaming viewer counts.
- MediaMTX for RTMP ingest, HLS playback, hooks, recordings, and the private API.
- Laravel queue worker.
- Laravel scheduler worker.
- Long-running `streaming:sync-viewer-counts` worker.
Default ports use the services' normal defaults:
- HTTP app/HLS proxy: `80`
- RTMP ingest: `1935`
- MediaMTX HLS inside Docker: `8888`
- MediaMTX API inside Docker: `9997`
- PostgreSQL inside Docker: `5432`
- Redis inside Docker: `6379`
Only HTTP and RTMP are published by default. HLS is served through Apache on `HLS_DOMAIN`, not directly from MediaMTX.
## Server Requirements
- Docker with Compose v2.
- Git.
- OpenSSL.
- Zip.
- Network access from the server to the Git repository.
- DNS for both `APP_DOMAIN` and `HLS_DOMAIN`.
- External TLS termination through a reverse proxy, load balancer, or CDN.
If a host-level reverse proxy already uses port `80`, set `APP_HTTP_PORT` to another local port and forward both hostnames to that port.
## First Deployment
```bash
cd docker-bundle
cp .env.example .env
nano .env
chmod +x scripts/deploy.sh
chmod +x scripts/backup.sh
./scripts/deploy.sh
```
At minimum, change these values in `.env`:
```env
GIT_REPOSITORY_URL=https://github.com/your-org/nyone.git
GIT_REF=main
APP_DOMAIN=nyone.example.com
HLS_DOMAIN=nyone-hls.example.com
PUBLIC_SCHEME=https
```
Leave `APP_KEY`, `POSTGRES_PASSWORD`, and `MEDIAMTX_SHARED_SECRET` empty on first deploy if you want `scripts/deploy.sh` to generate them.
The deploy script will:
1. Create `.env` from `.env.example` if needed.
2. Generate missing secrets.
3. Clone or update the app into `runtime/source`.
4. Validate the Compose config.
5. Build Docker images.
6. Start PostgreSQL and Redis.
7. Run Laravel migrations.
8. Start Apache, MediaMTX, queue, scheduler, and viewer-sync services.
## Runtime Configuration
This bundle does not use the cloned repository's `.env` file for production settings. Docker Compose passes configuration from `docker-bundle/.env` into the containers.
Important values:
- `APP_DOMAIN`: Laravel app hostname.
- `HLS_DOMAIN`: HLS playback hostname. Must differ from `APP_DOMAIN`.
- `APP_HTTP_PORT`: host port published by Apache, default `80`.
- `RTMP_PUBLIC_PORT`: host RTMP port, default `1935`.
- `PUBLIC_SCHEME`: usually `https` behind external TLS.
- `SEED_DATABASE`: defaults to `false`; set to `true` only if you intentionally want the app seeder to run.
- `QUEUE_NAMES`: queue list for the Laravel database queue worker, default `default`.
The MediaMTX config is Docker-owned at `docker/mediamtx/mediamtx.yml.template`; it does not mount or depend on `deploy/mediamtx.yml` from the cloned repository.
## Operations
Run commands from the `docker-bundle/` directory.
```bash
docker compose --env-file .env -f docker-compose.yml ps
docker compose --env-file .env -f docker-compose.yml logs -f app
docker compose --env-file .env -f docker-compose.yml logs -f mediamtx
docker compose --env-file .env -f docker-compose.yml exec app php artisan migrate:status
docker compose --env-file .env -f docker-compose.yml exec app php artisan streaming:sync-viewer-counts --once -v
```
Redeploy after new Git changes:
```bash
./scripts/deploy.sh
```
Create a restore-ready backup zip:
```bash
./scripts/backup.sh
```
For the most consistent app storage and recording backup, run it during a maintenance window and let the script stop writer services while it archives files:
```bash
./scripts/backup.sh --stop-writers
```
Backups are written to `backups/` by default. Each zip contains deployment files, `.env`, `runtime/source`, PostgreSQL dump, app storage, Redis data, checksums, restore notes, and a `restore.sh` helper.
Restore on a fresh server after installing Docker and Compose:
```bash
unzip nyone-backup-YYYYMMDD-HHMMSS.zip
cd nyone-backup-YYYYMMDD-HHMMSS
chmod +x restore.sh
./restore.sh /opt/nyone/docker-bundle
```
Stop the stack:
```bash
docker compose --env-file .env -f docker-compose.yml down
```
Do not remove volumes unless you intend to delete database, Redis, and stored app media data.
## External Proxy Notes
Forward both `APP_DOMAIN` and `HLS_DOMAIN` to the Apache container's published HTTP port. Preserve the `Host` header and set `X-Forwarded-Proto: https` when TLS is terminated outside Docker.
Expose TCP `1935` publicly for OBS/RTMP ingest. Do not expose MediaMTX API port `9997` publicly.
## Nginx TLS Proxy Example
Use this pattern when Nginx runs on the host and handles public HTTP/HTTPS. In that setup, let Nginx own ports `80` and `443`, and bind the Docker Apache service to a private local port:
```env
APP_HTTP_PORT=127.0.0.1:8080
PUBLIC_SCHEME=https
APP_DOMAIN=app.example.com
HLS_DOMAIN=hls.example.com
RTMP_PUBLIC_PORT=1935
```
Run `./scripts/deploy.sh` after changing `.env`, then point Nginx at `http://127.0.0.1:8080`. Replace the example domains and certificate paths before enabling the config.
```nginx
# /etc/nginx/sites-available/nyone-docker-bundle.conf
server {
listen 80;
listen [::]:80;
server_name app.example.com hls.example.com;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
client_max_body_size 100M;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 120s;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name hls.example.com;
ssl_certificate /etc/letsencrypt/live/hls.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/hls.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-Proto https;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
}
}
```
Enable and validate on the server:
```bash
sudo ln -s /etc/nginx/sites-available/nyone-docker-bundle.conf /etc/nginx/sites-enabled/nyone-docker-bundle.conf
sudo nginx -t
sudo systemctl reload nginx
```
Keep RTMP separate from this HTTP proxy. Open TCP `1935` to the Docker host so OBS can publish directly to MediaMTX.

76
docker-bundle/SKILL.md Normal file
View File

@@ -0,0 +1,76 @@
---
name: docker-bundle-deployment
description: Work on the Nyone docker-bundle deployment bundle under docker-bundle/. Use when modifying Docker Compose, Apache, MediaMTX, PostgreSQL, Redis, Laravel queue/scheduler/viewer-sync services, deploy.sh, deployment README files, or runtime environment defaults for this self-contained Docker deployment.
---
# Nyone docker-bundle Deployment
## Core Rules
- Keep deployment changes inside `docker-bundle/` unless the user explicitly requests application code changes.
- Treat `docker-bundle/.env` and `docker-bundle/runtime/` as server-local generated state; do not commit or depend on them.
- Do not make this bundle depend on the cloned app repository's `.env` or `deploy/mediamtx.yml`.
- Keep MediaMTX config Docker-owned at `docker/mediamtx/mediamtx.yml.template`.
- Keep `APP_DOMAIN` and `HLS_DOMAIN` separate because Apache uses separate virtual hosts.
- Do not run `scripts/deploy.sh`, `docker compose up`, or image builds unless the user confirms this is a deployment server.
## Architecture
- `scripts/deploy.sh` is the entrypoint for server deployment.
- `scripts/backup.sh` creates restore-ready zip backups for the docker-bundle deployment.
- `docker-compose.yml` defines `app`, `db`, `redis`, `mediamtx`, `queue`, `scheduler`, `viewer-sync`, and one-shot `migrate`.
- `docker/app/Dockerfile` builds the Laravel Apache image from `runtime/source`.
- `docker/app/entrypoint.sh` selects behavior by `CONTAINER_ROLE`.
- `docker/app/apache/nyone.conf.template` serves Laravel on `APP_DOMAIN` and proxies HLS for `HLS_DOMAIN`.
- `docker/mediamtx/entrypoint.sh` renders the MediaMTX template at container start.
## Port Defaults
- Apache published HTTP: `80`.
- MediaMTX RTMP: `1935`.
- MediaMTX HLS internal: `8888`.
- MediaMTX API internal: `9997`.
- PostgreSQL internal: `5432`.
- Redis internal: `6379`.
Only publish HTTP and RTMP by default. Keep MediaMTX HLS behind Apache and keep the API private on the Docker network.
## Editing Guidance
- When adding an environment option, update `.env.example`, `docker-compose.yml`, and any entrypoint/template that consumes it.
- When changing MediaMTX ports, update Compose environment, published ports, entrypoint defaults, and the template together.
- When changing Laravel runtime behavior, update the shared `x-app-environment` block unless the value belongs to one role only.
- When changing queue behavior, prefer env-driven options on the `queue` service and `docker/app/entrypoint.sh`.
- Do not run the long-lived Docker `viewer-sync` service with `--isolated`; a stale cache lock can make the command exit cleanly and trigger an endless container restart loop.
- Keep `npm run build` after PHP setup and Composer install in `docker/app/Dockerfile`; the Wayfinder Vite plugin invokes `php artisan wayfinder:generate` during the build. The asset stage must also provide safe build-time Laravel env values, create Laravel writable directories such as `storage/framework/views`, and run `php artisan wayfinder:generate --with-form --no-interaction` before `npm run build` so failures are visible in Docker logs.
- Do not call `php artisan storage:link --force` as `www-data` during container startup; `public/storage` lives in the root-owned image filesystem. Create the symlink as root in the entrypoint if it is missing.
- Keep deploy script behavior idempotent: repeat runs should update the Git checkout, rebuild, migrate, and restart services without deleting volumes.
- Keep backup behavior restore-ready: include deployment files, `.env`, source checkout when present, PostgreSQL dump, app storage volume, Redis volume, checksums, restore notes, and a restore helper in one zip.
- Keep generated secrets in `.env`; do not bake secrets into images or templates.
## Nginx TLS Proxy Guidance
- Document Nginx as a host-level TLS reverse proxy in `README.md`; do not add Nginx as another Compose service unless the user asks.
- Keep Docker Apache as the upstream for both app and HLS hostnames. Nginx should proxy both domains to the published Apache port and preserve the `Host` header so Apache can select the correct virtual host.
- Recommend `APP_HTTP_PORT=127.0.0.1:8080` when Nginx owns host ports `80` and `443`.
- Include `X-Forwarded-Proto https`, `X-Forwarded-Port 443`, `X-Forwarded-Host`, `X-Real-IP`, and `X-Forwarded-For` in proxy examples.
- Keep HLS proxy examples buffering off and cache headers set to no-store.
- Keep RTMP outside the HTTP proxy guidance unless explicitly using Nginx stream proxying; the default is direct TCP `1935` to MediaMTX through Docker.
## Static Verification
Use static checks when not on a deployment server:
```bash
git diff --check
rg "1993[5]|1998[8]|80[8]0|25[2]5|nyone[.]net|nyone-hls[.]net|change-this-secre[t]" docker-bundle -n
bash -n docker-bundle/scripts/backup.sh
```
If Docker is available and the user allows non-deploy validation, validate only the Compose config:
```bash
docker compose --env-file docker-bundle/.env.example -f docker-bundle/docker-compose.yml config
```
Do not start containers during local verification unless the user explicitly asks for runtime testing.

View File

@@ -0,0 +1,474 @@
# Fresh Ubuntu VPS Deployment for nyone.app
This guide explains how to deploy Nyone from only the `docker-bundle/` folder on a fresh Ubuntu VPS.
It assumes:
- App domain: `nyone.app`
- HLS playback domain: `hls.nyone.app`
- RTMP ingest URL: `rtmp://nyone.app:1935`
- Docker Apache is private on `127.0.0.1:8080`
- Host Nginx handles public HTTP, HTTPS, and SSL certificates
The application repository itself does not need to exist on the server before deployment. The deploy script clones it into `docker-bundle/runtime/source`.
## 1. Point DNS to the VPS
Create DNS records before requesting SSL certificates:
```text
nyone.app A <VPS_PUBLIC_IPV4>
hls.nyone.app A <VPS_PUBLIC_IPV4>
```
If your DNS provider has an HTTP proxy mode, make sure TCP `1935` still reaches the VPS directly for RTMP. With Cloudflare, that usually means the RTMP hostname must be DNS-only, not proxied.
Wait until DNS resolves from the VPS or your local machine:
```bash
dig +short nyone.app
dig +short hls.nyone.app
```
## 2. SSH into the VPS
```bash
ssh root@<VPS_PUBLIC_IPV4>
```
Update the server:
```bash
apt-get update
apt-get upgrade -y
apt-get install -y ca-certificates curl git gnupg lsb-release nginx openssl snapd ufw unzip zip
```
## 3. Configure the firewall
Allow SSH, HTTP/HTTPS, and RTMP:
```bash
ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw allow 1935/tcp
ufw --force enable
ufw status
```
Docker can publish container ports outside some UFW rules. In this deployment, Apache is bound to `127.0.0.1:8080`, while RTMP is intentionally public on `1935`.
## 4. Install Docker Engine and Compose plugin
Remove conflicting packages if they exist:
```bash
for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do
apt-get remove -y "$pkg" || true
done
```
Install Docker from Docker's official Ubuntu apt repository:
```bash
apt-get update
apt-get install -y ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
tee /etc/apt/sources.list.d/docker.sources >/dev/null <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker
docker run hello-world
```
## 5. Copy only the docker-bundle folder to the server
Create the deployment directory on the VPS:
```bash
mkdir -p /opt/nyone
```
From your local machine, copy the folder:
```bash
rsync -av --exclude '.env' --exclude 'runtime' docker-bundle/ root@<VPS_PUBLIC_IPV4>:/opt/nyone/docker-bundle/
```
Back on the VPS:
```bash
cd /opt/nyone/docker-bundle
cp .env.example .env
chmod +x scripts/deploy.sh
chmod +x scripts/backup.sh
```
## 6. Configure docker-bundle/.env
Edit the Docker deployment environment:
```bash
nano /opt/nyone/docker-bundle/.env
```
Use these values for `nyone.app`:
```env
COMPOSE_PROJECT_NAME=nyone
GIT_REPOSITORY_URL=git@github.com:your-org/nyone.git
GIT_REF=main
PUBLIC_SCHEME=https
APP_DOMAIN=nyone.app
HLS_DOMAIN=hls.nyone.app
APP_HTTP_PORT=127.0.0.1:8080
RTMP_PUBLIC_PORT=1935
APP_NAME=Nyone
APP_ENV=production
APP_DEBUG=false
POSTGRES_DB=nyone
POSTGRES_USER=nyone
SEED_DATABASE=false
APP_OPTIMIZE_ON_BOOT=true
QUEUE_NAMES=default
```
Leave these empty on first deploy if you want the deploy script to generate them:
```env
APP_KEY=
POSTGRES_PASSWORD=
MEDIAMTX_SHARED_SECRET=
```
If the repository is public, you can use an HTTPS clone URL instead:
```env
GIT_REPOSITORY_URL=https://github.com/your-org/nyone.git
```
If `GIT_REPOSITORY_URL` is private, install a deploy key on the VPS:
```bash
ssh-keygen -t ed25519 -C "nyone-vps-deploy" -f ~/.ssh/nyone_deploy
cat ~/.ssh/nyone_deploy.pub
```
Add the printed public key as a read-only deploy key in the Git provider, then configure SSH:
```bash
cat >> ~/.ssh/config <<'EOF'
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/nyone_deploy
IdentitiesOnly yes
EOF
chmod 600 ~/.ssh/config ~/.ssh/nyone_deploy
```
Confirm Git access:
```bash
ssh -T git@github.com
```
## 7. Run the Docker deployment
```bash
cd /opt/nyone/docker-bundle
./scripts/deploy.sh
```
The script will clone the app into `runtime/source`, build the images, run migrations, and start:
- `app`
- `db`
- `redis`
- `mediamtx`
- `queue`
- `scheduler`
- `viewer-sync`
Check status:
```bash
docker compose --env-file .env -f docker-compose.yml ps
docker compose --env-file .env -f docker-compose.yml logs --tail=100 app
docker compose --env-file .env -f docker-compose.yml logs --tail=100 mediamtx
```
## 8. Install Certbot
Install Certbot through snap:
```bash
snap install core
snap refresh core
apt-get remove -y certbot || true
snap install --classic certbot
ln -sf /snap/bin/certbot /usr/local/bin/certbot
```
Create the webroot used for certificate challenges:
```bash
mkdir -p /var/www/certbot
```
## 9. Create temporary Nginx config for SSL issuance
```bash
tee /etc/nginx/sites-available/nyone-temp.conf >/dev/null <<'EOF'
server {
listen 80;
listen [::]:80;
server_name nyone.app hls.nyone.app;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 200 "nyone certificate setup\n";
add_header Content-Type text/plain;
}
}
EOF
ln -sf /etc/nginx/sites-available/nyone-temp.conf /etc/nginx/sites-enabled/nyone-temp.conf
rm -f /etc/nginx/sites-enabled/default
nginx -t
systemctl reload nginx
```
Request the certificate:
```bash
certbot certonly --webroot \
-w /var/www/certbot \
-d nyone.app \
-d hls.nyone.app
```
The certificate files should be under:
```text
/etc/letsencrypt/live/nyone.app/fullchain.pem
/etc/letsencrypt/live/nyone.app/privkey.pem
```
## 10. Install final Nginx proxy config
```bash
tee /etc/nginx/sites-available/nyone.conf >/dev/null <<'EOF'
server {
listen 80;
listen [::]:80;
server_name nyone.app hls.nyone.app;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name nyone.app;
ssl_certificate /etc/letsencrypt/live/nyone.app/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/nyone.app/privkey.pem;
client_max_body_size 100M;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 120s;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name hls.nyone.app;
ssl_certificate /etc/letsencrypt/live/nyone.app/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/nyone.app/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-Proto https;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
}
}
EOF
ln -sf /etc/nginx/sites-available/nyone.conf /etc/nginx/sites-enabled/nyone.conf
rm -f /etc/nginx/sites-enabled/nyone-temp.conf
nginx -t
systemctl reload nginx
```
## 11. Test certificate renewal
```bash
certbot renew --dry-run
```
## 12. Verify the deployment
Check the app health endpoint:
```bash
curl -I https://nyone.app/up
```
Check container status:
```bash
cd /opt/nyone/docker-bundle
docker compose --env-file .env -f docker-compose.yml ps
```
Check database migrations:
```bash
docker compose --env-file .env -f docker-compose.yml exec app php artisan migrate:status
```
Run one viewer-count sync pass:
```bash
docker compose --env-file .env -f docker-compose.yml exec app php artisan streaming:sync-viewer-counts --once -v
```
Check that MediaMTX API is not exposed on the host. This should fail because the API is only available inside the Docker network:
```bash
curl -I http://127.0.0.1:9997/v3/config/get
```
Do not expose port `9997` on the public firewall.
## 13. OBS and playback values
Creators should use:
```text
OBS server: rtmp://nyone.app:1935
OBS stream key: <channel-slug>?token=<generated-stream-key>
```
Viewer playback URLs generated by Laravel should look like:
```text
https://hls.nyone.app/<channel-slug>/index.m3u8
```
## 14. Redeploy after app changes
When the Git branch changes:
```bash
cd /opt/nyone/docker-bundle
./scripts/deploy.sh
```
Useful logs:
```bash
docker compose --env-file .env -f docker-compose.yml logs -f app
docker compose --env-file .env -f docker-compose.yml logs -f queue
docker compose --env-file .env -f docker-compose.yml logs -f scheduler
docker compose --env-file .env -f docker-compose.yml logs -f viewer-sync
docker compose --env-file .env -f docker-compose.yml logs -f mediamtx
```
## 15. Create a restore-ready backup
Run backups from the docker-bundle deployment directory:
```bash
cd /opt/nyone/docker-bundle
./scripts/backup.sh
```
For the safest storage and recording snapshot, use a maintenance window and stop writer services while the backup runs:
```bash
./scripts/backup.sh --stop-writers
```
The final archive is written to:
```text
/opt/nyone/docker-bundle/backups/nyone-backup-YYYYMMDD-HHMMSS.zip
```
Copy that one zip file off the VPS. It contains the docker-bundle deployment files, `.env`, source checkout, PostgreSQL dump, app storage, Redis data, checksums, restore notes, and a restore helper.
Restore on a fresh Ubuntu VPS after installing Docker and copying the zip:
```bash
unzip nyone-backup-YYYYMMDD-HHMMSS.zip
cd nyone-backup-YYYYMMDD-HHMMSS
chmod +x restore.sh
./restore.sh /opt/nyone/docker-bundle
```
If `/opt/nyone/docker-bundle` already exists and you intentionally want to replace it:
```bash
./restore.sh --replace /opt/nyone/docker-bundle
```
Host-level Nginx files and Let's Encrypt certificates live outside `docker-bundle/`, so recreate or back them up separately.
## References
- Docker Engine on Ubuntu: https://docs.docker.com/engine/install/ubuntu/
- Certbot Nginx instructions: https://certbot.eff.org/instructions
- Laravel trusted proxy behavior: https://laravel.com/docs/13.x/requests#configuring-trusted-proxies

View File

@@ -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:-25}
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:-1935}
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: 8888
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:-80}: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: 1935
HLS_INTERNAL_PORT: 8888
ports:
- "${RTMP_PUBLIC_PORT:-1935}:1935"
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:

View File

@@ -0,0 +1,121 @@
# syntax=docker/dockerfile:1
FROM php:8.4-apache-bookworm AS php-base
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 \
libsqlite3-dev \
libzip-dev \
netcat-openbsd \
unzip \
&& docker-php-ext-install -j"$(nproc)" \
bcmath \
curl \
intl \
mbstring \
opcache \
pcntl \
pdo_pgsql \
pdo_sqlite \
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
FROM php-base AS composer-deps
COPY runtime/source/composer.json runtime/source/composer.lock ./
RUN composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader --no-scripts
FROM php-base AS assets
ENV APP_NAME=Nyone \
APP_ENV=production \
APP_DEBUG=false \
APP_KEY=base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA \
APP_URL=http://localhost \
LOG_CHANNEL=stderr \
DB_CONNECTION=sqlite \
DB_DATABASE=:memory: \
CACHE_STORE=array \
SESSION_DRIVER=array \
QUEUE_CONNECTION=sync \
MAIL_MAILER=log \
BROADCAST_CONNECTION=log \
FILESYSTEM_DISK=local \
VITE_APP_NAME=Nyone \
STREAMING_VIEWER_COUNT_CACHE_STORE=array \
MEDIAMTX_SHARED_SECRET=build-time-secret
COPY --from=node:22-bookworm-slim /usr/local/bin/node /usr/local/bin/node
COPY --from=node:22-bookworm-slim /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
&& ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
COPY runtime/source/composer.json runtime/source/composer.lock ./
COPY --from=composer-deps /var/www/html/vendor ./vendor
COPY runtime/source/package.json runtime/source/package-lock.json ./
RUN npm ci
COPY runtime/source/ ./
RUN mkdir -p \
storage/app/private/recordings \
storage/app/public \
storage/framework/cache/data \
storage/framework/sessions \
storage/framework/views \
storage/logs \
bootstrap/cache \
&& composer dump-autoload --optimize --no-dev \
&& php artisan package:discover --ansi \
&& php artisan route:clear \
&& php artisan wayfinder:generate --with-form --no-interaction \
&& npm run build
FROM php-base AS app
COPY runtime/source/composer.json runtime/source/composer.lock ./
COPY --from=composer-deps /var/www/html/vendor ./vendor
COPY runtime/source/ ./
COPY --from=assets /var/www/html/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 \
&& if [ ! -e public/storage ]; then ln -s ../storage/app/public public/storage; fi \
&& chown -R www-data:www-data storage bootstrap/cache \
&& chmod +x /usr/local/bin/nyone-entrypoint
ENTRYPOINT ["nyone-entrypoint"]
CMD ["app"]

View File

@@ -0,0 +1,43 @@
ServerName ${APP_DOMAIN}
<VirtualHost *:80>
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
<Directory /var/www/html/public>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
<FilesMatch "\.php$">
SetHandler application/x-httpd-php
</FilesMatch>
ErrorLog /proc/self/fd/2
CustomLog /proc/self/fd/1 combined
</VirtualHost>
<VirtualHost *:80>
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
</VirtualHost>

View File

@@ -0,0 +1,143 @@
#!/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 docker-bundle/scripts/deploy.sh or set it in docker-bundle/.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
}
ensure_public_storage_link() {
mkdir -p public storage/app/public
if [[ -L public/storage || -e public/storage ]]; then
return 0
fi
ln -s ../storage/app/public public/storage
}
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
ensure_public_storage_link
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 \
--interval="${STREAMING_VIEWER_SYNC_INTERVAL:-2}"
;;
artisan)
shift || true
exec gosu www-data php artisan "$@"
;;
*)
exec "$@"
;;
esac

View File

@@ -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

View File

@@ -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"]

View File

@@ -0,0 +1,34 @@
#!/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 docker-bundle/scripts/deploy.sh or set it in docker-bundle/.env."
fi
: "${APP_URL:?APP_URL is required.}"
: "${HLS_PUBLIC_URL:?HLS_PUBLIC_URL is required.}"
: "${RTMP_INTERNAL_PORT:=1935}"
: "${HLS_INTERNAL_PORT:=8888}"
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."

View File

@@ -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

View File

@@ -0,0 +1,452 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_FILE="$ROOT_DIR/.env"
COMPOSE_FILE="$ROOT_DIR/docker-compose.yml"
SOURCE_DIR="$ROOT_DIR/runtime/source"
BACKUP_ROOT="$ROOT_DIR/backups"
BACKUP_NAME="nyone-backup-$(date -u +%Y%m%d-%H%M%S)"
STOP_WRITERS=false
FORCE=false
STAGE_DIR=""
RESTART_WRITERS=false
info() {
printf '[nyone-backup] %s\n' "$*"
}
warn() {
printf '[nyone-backup] WARNING: %s\n' "$*" >&2
}
fail() {
printf '[nyone-backup] ERROR: %s\n' "$*" >&2
exit 1
}
usage() {
cat <<'USAGE'
Usage:
scripts/backup.sh [options]
Options:
--output-dir PATH Directory for the final zip file. Default: docker-bundle/backups
--name NAME Backup folder/zip base name. Default: nyone-backup-UTC_TIMESTAMP
--stop-writers Stop app, MediaMTX, queue, scheduler, and viewer-sync while files are archived
--force Replace an existing zip with the same name
-h, --help Show this help
The script creates one restore-ready zip containing:
- docker-bundle deployment files and .env
- runtime/source checkout when present, excluding dependencies and Laravel runtime cache/log files
- PostgreSQL SQL dump
- app storage volume archive
- Redis data volume archive
- restore.sh and README_RESTORE.md
USAGE
}
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"
}
run_compose() {
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"
}
cleanup() {
local exit_code=$?
if [ "$RESTART_WRITERS" = true ]; then
info "Restarting writer services."
run_compose up -d app mediamtx queue scheduler viewer-sync >/dev/null 2>&1 || true
fi
if [ -n "$STAGE_DIR" ] && [[ "$STAGE_DIR" == "$BACKUP_ROOT"/.*.stage ]]; then
rm -rf "$STAGE_DIR"
fi
exit "$exit_code"
}
trap cleanup EXIT
while [ "$#" -gt 0 ]; do
case "$1" in
--output-dir)
[ "$#" -ge 2 ] || fail "--output-dir requires a value."
BACKUP_ROOT="$2"
shift 2
;;
--name)
[ "$#" -ge 2 ] || fail "--name requires a value."
BACKUP_NAME="$2"
shift 2
;;
--stop-writers)
STOP_WRITERS=true
shift
;;
--force)
FORCE=true
shift
;;
-h|--help)
usage
exit 0
;;
*)
fail "Unknown option: $1"
;;
esac
done
case "$BACKUP_NAME" in
*/*|*\\*)
fail "--name must be a file name, not a path."
;;
esac
[ -f "$ENV_FILE" ] || fail "$ENV_FILE was not found. Create it before running a backup."
[ -f "$COMPOSE_FILE" ] || fail "$COMPOSE_FILE was not found."
require_command docker
require_command tar
require_command gzip
require_command zip
require_command sha256sum
mkdir -p "$BACKUP_ROOT"
BACKUP_ROOT="$(cd "$BACKUP_ROOT" && pwd)"
STAGE_DIR="$BACKUP_ROOT/.${BACKUP_NAME}.stage"
ARCHIVE_DIR="$STAGE_DIR/$BACKUP_NAME"
ZIP_PATH="$BACKUP_ROOT/$BACKUP_NAME.zip"
if [ -e "$ZIP_PATH" ] && [ "$FORCE" != true ]; then
fail "$ZIP_PATH already exists. Use --force or choose another --name."
fi
rm -rf "$STAGE_DIR"
mkdir -p "$ARCHIVE_DIR"/{database,deployment,metadata,volumes}
info "Validating Compose configuration."
run_compose config > "$ARCHIVE_DIR/metadata/compose.resolved.yml"
if [ "$STOP_WRITERS" = true ]; then
info "Stopping writer services for a consistent file backup."
run_compose stop app mediamtx queue scheduler viewer-sync >/dev/null || true
RESTART_WRITERS=true
fi
info "Writing PostgreSQL dump."
run_compose exec -T db sh -c 'PGPASSWORD="$POSTGRES_PASSWORD" pg_dump -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists --no-owner --no-privileges' \
| gzip -9 > "$ARCHIVE_DIR/database/postgres.sql.gz"
info "Archiving app storage volume."
run_compose run --rm --no-deps -T --user root --entrypoint tar \
-v "$ARCHIVE_DIR/volumes:/backup" \
app -czf /backup/app-storage.tar.gz -C /var/www/html/storage .
info "Archiving Redis data volume."
if ! run_compose exec -T redis redis-cli SAVE >/dev/null; then
warn "Could not force a Redis SAVE before archiving; continuing with the current volume files."
fi
run_compose run --rm --no-deps -T --user root --entrypoint tar \
-v "$ARCHIVE_DIR/volumes:/backup" \
redis -czf /backup/redis-data.tar.gz -C /data .
info "Archiving docker-bundle deployment files."
tar -czf "$ARCHIVE_DIR/deployment/docker-bundle-files.tar.gz" \
-C "$(dirname "$ROOT_DIR")" \
--exclude='docker-bundle/backups' \
--exclude='docker-bundle/runtime' \
"$(basename "$ROOT_DIR")"
if [ -d "$SOURCE_DIR" ]; then
info "Archiving runtime/source checkout."
tar -czf "$ARCHIVE_DIR/deployment/runtime-source.tar.gz" \
-C "$(dirname "$SOURCE_DIR")" \
--exclude='source/.env' \
--exclude='source/node_modules' \
--exclude='source/vendor' \
--exclude='source/public/build' \
--exclude='source/storage/app/private/recordings' \
--exclude='source/storage/framework/cache' \
--exclude='source/storage/framework/sessions' \
--exclude='source/storage/framework/testing' \
--exclude='source/storage/framework/views' \
--exclude='source/storage/logs' \
source
else
warn "$SOURCE_DIR was not found; the archive will rely on GIT_REPOSITORY_URL for source restoration."
fi
info "Writing metadata."
{
printf 'created_at_utc=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf 'compose_project_name=%s\n' "$(env_value COMPOSE_PROJECT_NAME)"
printf 'app_domain=%s\n' "$(env_value APP_DOMAIN)"
printf 'hls_domain=%s\n' "$(env_value HLS_DOMAIN)"
printf 'git_repository_url=%s\n' "$(env_value GIT_REPOSITORY_URL)"
printf 'git_ref=%s\n' "$(env_value GIT_REF)"
printf 'backup_name=%s\n' "$BACKUP_NAME"
printf 'stop_writers=%s\n' "$STOP_WRITERS"
} > "$ARCHIVE_DIR/metadata/manifest.env"
docker version > "$ARCHIVE_DIR/metadata/docker-version.txt" 2>&1 || true
docker compose version > "$ARCHIVE_DIR/metadata/docker-compose-version.txt" 2>&1 || true
run_compose ps > "$ARCHIVE_DIR/metadata/container-status.txt" 2>&1 || true
if [ -d "$SOURCE_DIR/.git" ]; then
{
git -C "$SOURCE_DIR" remote -v
git -C "$SOURCE_DIR" rev-parse HEAD
git -C "$SOURCE_DIR" status --short
} > "$ARCHIVE_DIR/metadata/source-git.txt" 2>&1 || true
fi
cat > "$ARCHIVE_DIR/README_RESTORE.md" <<'RESTORE_README'
# Nyone docker-bundle Backup Restore
This archive contains everything managed by the `docker-bundle/` deployment bundle:
- deployment files and `.env`
- `runtime/source` checkout when it existed at backup time
- PostgreSQL dump
- Laravel app storage volume
- Redis data volume
Host-level files outside `docker-bundle/`, such as Nginx site files and Let's Encrypt certificates, are not included.
## Restore on a fresh server
Install Docker and the Compose plugin first. Then extract this zip and run:
```bash
chmod +x restore.sh
./restore.sh /opt/nyone/docker-bundle
```
If the target already exists and you intentionally want to replace it:
```bash
./restore.sh --replace /opt/nyone/docker-bundle
```
The restore helper copies the deployment bundle, restores `runtime/source` when present, clones it from `.env` when missing, rebuilds the images, restores PostgreSQL, restores the app storage and Redis volumes, and starts the app services.
After restore, re-create host-level Nginx/TLS configuration if this is a new VPS.
RESTORE_README
cat > "$ARCHIVE_DIR/restore.sh" <<'RESTORE_SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET_DIR="/opt/nyone/docker-bundle"
REPLACE=false
TMP_DIR=""
info() {
printf '[nyone-restore] %s\n' "$*"
}
fail() {
printf '[nyone-restore] ERROR: %s\n' "$*" >&2
exit 1
}
usage() {
cat <<'USAGE'
Usage:
./restore.sh [--replace] [target-dir]
Example:
./restore.sh /opt/nyone/docker-bundle
./restore.sh --replace /opt/nyone/docker-bundle
USAGE
}
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "$1 is required."
}
env_value() {
local key="$1"
local env_file="$TARGET_DIR/.env"
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"
}
ensure_source_checkout() {
local repository_url
local git_ref
if [ -d "$TARGET_DIR/runtime/source" ]; then
return 0
fi
repository_url="$(env_value GIT_REPOSITORY_URL)"
git_ref="$(env_value GIT_REF)"
git_ref="${git_ref:-main}"
[ -n "$repository_url" ] || fail "runtime/source is missing and GIT_REPOSITORY_URL is not set in .env."
require_command git
info "Cloning runtime/source from GIT_REPOSITORY_URL."
mkdir -p "$TARGET_DIR/runtime"
git clone "$repository_url" "$TARGET_DIR/runtime/source"
git -C "$TARGET_DIR/runtime/source" checkout "$git_ref"
}
cleanup() {
if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
rm -rf "$TMP_DIR"
fi
}
trap cleanup EXIT
while [ "$#" -gt 0 ]; do
case "$1" in
--replace)
REPLACE=true
shift
;;
-h|--help)
usage
exit 0
;;
*)
TARGET_DIR="$1"
shift
;;
esac
done
[ "$TARGET_DIR" != "/" ] || fail "Refusing to restore into /."
[ -f "$BACKUP_DIR/deployment/docker-bundle-files.tar.gz" ] || fail "deployment/docker-bundle-files.tar.gz is missing."
[ -f "$BACKUP_DIR/database/postgres.sql.gz" ] || fail "database/postgres.sql.gz is missing."
[ -f "$BACKUP_DIR/volumes/app-storage.tar.gz" ] || fail "volumes/app-storage.tar.gz is missing."
require_command docker
require_command tar
require_command gzip
TARGET_PARENT="$(dirname "$TARGET_DIR")"
mkdir -p "$TARGET_PARENT"
if [ -e "$TARGET_DIR" ]; then
if [ "$REPLACE" != true ]; then
fail "$TARGET_DIR already exists. Use --replace if you want to replace it."
fi
if [ -f "$TARGET_DIR/docker-compose.yml" ] && [ -f "$TARGET_DIR/.env" ]; then
info "Stopping existing Compose stack."
docker compose --env-file "$TARGET_DIR/.env" -f "$TARGET_DIR/docker-compose.yml" down || true
fi
rm -rf "$TARGET_DIR"
fi
TMP_DIR="$(mktemp -d)"
info "Restoring docker-bundle files."
tar -xzf "$BACKUP_DIR/deployment/docker-bundle-files.tar.gz" -C "$TMP_DIR"
[ -d "$TMP_DIR/docker-bundle" ] || fail "Backup did not contain a docker-bundle directory."
mv "$TMP_DIR/docker-bundle" "$TARGET_DIR"
if [ -f "$BACKUP_DIR/deployment/runtime-source.tar.gz" ]; then
info "Restoring runtime/source."
mkdir -p "$TARGET_DIR/runtime"
tar -xzf "$BACKUP_DIR/deployment/runtime-source.tar.gz" -C "$TARGET_DIR/runtime"
fi
chmod +x "$TARGET_DIR"/scripts/*.sh
cd "$TARGET_DIR"
ensure_source_checkout
run_compose() {
docker compose --env-file "$TARGET_DIR/.env" -f "$TARGET_DIR/docker-compose.yml" "$@"
}
info "Validating Compose configuration."
run_compose config >/dev/null
info "Building Docker images."
run_compose build
info "Starting PostgreSQL."
run_compose up -d db
run_compose exec -T db sh -c 'until pg_isready -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"; do sleep 2; done'
info "Restoring PostgreSQL dump."
gzip -dc "$BACKUP_DIR/database/postgres.sql.gz" \
| run_compose exec -T db sh -c 'PGPASSWORD="$POSTGRES_PASSWORD" psql -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1'
info "Restoring app storage volume."
run_compose run --rm --no-deps -T --user root --entrypoint sh \
-v "$BACKUP_DIR/volumes:/backup:ro" \
app -c 'find /var/www/html/storage -mindepth 1 -maxdepth 1 -exec rm -rf {} + && tar -xzf /backup/app-storage.tar.gz -C /var/www/html/storage'
if [ -f "$BACKUP_DIR/volumes/redis-data.tar.gz" ]; then
info "Restoring Redis data volume."
run_compose up -d redis
run_compose stop redis >/dev/null || true
run_compose run --rm --no-deps -T --user root --entrypoint sh \
-v "$BACKUP_DIR/volumes:/backup:ro" \
redis -c 'find /data -mindepth 1 -maxdepth 1 -exec rm -rf {} + && tar -xzf /backup/redis-data.tar.gz -C /data'
fi
info "Starting application services."
run_compose up -d --remove-orphans app mediamtx queue scheduler viewer-sync redis
run_compose ps
RESTORE_SCRIPT
chmod +x "$ARCHIVE_DIR/restore.sh"
info "Writing checksums."
(
cd "$ARCHIVE_DIR"
find deployment database volumes metadata -type f -print0 | sort -z | xargs -0 sha256sum > checksums.sha256
)
info "Creating zip archive."
rm -f "$ZIP_PATH"
(
cd "$STAGE_DIR"
zip -rq "$ZIP_PATH" "$BACKUP_NAME"
)
info "Backup ready: $ZIP_PATH"

View File

@@ -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