Compare commits

..

10 Commits

Author SHA1 Message Date
b3bb160b90 Splitting MediaRTX from Apache in docker-bundle
All checks were successful
tests / ci (8.5) (push) Successful in 4m0s
linter / quality (push) Successful in 1m16s
tests / ci (8.4) (push) Successful in 1m36s
2026-05-20 00:59:19 +03:30
cb62a0bae6 replace dockerized to docker-bundle 2026-05-19 20:25:15 +03:30
f302d605d2 added the backup flow. 2026-05-19 20:22:10 +03:30
cdb89d3287 fix running viewer-sync error on docker deploy 2026-05-19 19:44:01 +03:30
da19b6a8a2 fix storage link error on docker deploy 2026-05-19 19:26:57 +03:30
81dd742120 provide a valid cache path for docker deploy 2026-05-19 19:13:34 +03:30
34a683b614 Fixed the Docker build stage that runs npm run build 2026-05-19 18:58:14 +03:30
c06e237cd1 move npm run build runs with php artisan available 2026-05-19 18:18:00 +03:30
0fb7531e63 replace nyone.net to nyone.app 2026-05-19 15:23:38 +03:30
b3b7ebf8d7 add md files 2026-05-19 15:20:06 +03:30
31 changed files with 1281 additions and 251 deletions

View File

@@ -2,7 +2,7 @@ APP_NAME=Nyone
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=https://nyone.net
APP_URL=https://nyone.app
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
@@ -64,7 +64,7 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
STREAMING_RTMP_INGEST_URL=rtmp://nyone.net:19935
STREAMING_RTMP_INGEST_URL=rtmp://nyone.app:19935
STREAMING_HLS_PUBLIC_URL=https://nyone-hls.net
STREAMING_MEDIAMTX_API_URL=http://127.0.0.1:9997
STREAMING_VIEWER_COUNT_CACHE_STORE=redis

View File

@@ -35,8 +35,8 @@ The project config is aligned to these production-style domains:
```env
APP_NAME=Nyone
APP_URL=https://nyone.net
STREAMING_RTMP_INGEST_URL=rtmp://nyone.net:19935
APP_URL=https://nyone.app
STREAMING_RTMP_INGEST_URL=rtmp://nyone.app:19935
STREAMING_HLS_PUBLIC_URL=https://nyone-hls.net
MEDIAMTX_SHARED_SECRET=change-this-secret
```

View File

@@ -52,7 +52,7 @@ return [
|
*/
'url' => env('APP_URL', 'https://nyone.net'),
'url' => env('APP_URL', 'https://nyone.app'),
/*
|--------------------------------------------------------------------------

View File

@@ -41,7 +41,7 @@ return [
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'https://nyone.net'), '/').'/storage',
'url' => rtrim(env('APP_URL', 'https://nyone.app'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,

View File

@@ -46,7 +46,7 @@ return [
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'https://nyone.net'), PHP_URL_HOST)),
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'https://nyone.app'), PHP_URL_HOST)),
],
'ses' => [

View File

@@ -12,7 +12,7 @@ return [
|
*/
'rtmp_ingest_url' => env('STREAMING_RTMP_INGEST_URL', 'rtmp://nyone.net:19935'),
'rtmp_ingest_url' => env('STREAMING_RTMP_INGEST_URL', 'rtmp://nyone.app:19935'),
'hls_public_url' => env('STREAMING_HLS_PUBLIC_URL', 'https://nyone-hls.net'),
/*

View File

@@ -17,7 +17,7 @@ class DatabaseSeeder extends Seeder
{
PlatformSetting::current();
$admin = User::query()->firstOrNew(['email' => 'admin@nyone.net']);
$admin = User::query()->firstOrNew(['email' => 'admin@nyone.app']);
$admin->forceFill([
'name' => 'Admin User',
'password' => 'password',
@@ -26,7 +26,7 @@ class DatabaseSeeder extends Seeder
'can_create_channel' => false,
])->save();
$official = User::query()->firstOrNew(['email' => 'chief@nyone.net']);
$official = User::query()->firstOrNew(['email' => 'chief@nyone.app']);
$official->forceFill([
'name' => 'Chief Nyone',
'password' => 'password',

View File

@@ -1,5 +1,5 @@
authMethod: http
authHTTPAddress: https://nyone.net/internal/mediamtx/auth?secret=change-this-secret
authHTTPAddress: https://nyone.app/internal/mediamtx/auth?secret=change-this-secret
authHTTPExclude:
- action: read
- action: playback
@@ -17,17 +17,17 @@ hls: yes
hlsAddress: :19988
hlsVariant: mpegts
hlsAllowOrigins:
- https://nyone.net
- https://nyone.app
pathDefaults:
record: yes
recordPath: ./storage/app/private/recordings/%path/%Y-%m-%d_%H-%M-%S-%f
runOnReady: curl -X POST "https://nyone.net/internal/mediamtx/ready?secret=change-this-secret" --data-urlencode "path=$MTX_PATH" --data-urlencode "source_type=$MTX_SOURCE_TYPE" --data-urlencode "source_id=$MTX_SOURCE_ID"
runOnReady: curl -X POST "https://nyone.app/internal/mediamtx/ready?secret=change-this-secret" --data-urlencode "path=$MTX_PATH" --data-urlencode "source_type=$MTX_SOURCE_TYPE" --data-urlencode "source_id=$MTX_SOURCE_ID"
runOnReadyRestart: no
runOnNotReady: curl -X POST "https://nyone.net/internal/mediamtx/not-ready?secret=change-this-secret" --data-urlencode "path=$MTX_PATH" --data-urlencode "source_type=$MTX_SOURCE_TYPE" --data-urlencode "source_id=$MTX_SOURCE_ID"
runOnRecordSegmentComplete: curl -X POST "https://nyone.net/internal/mediamtx/recording-complete?secret=change-this-secret" --data-urlencode "path=$MTX_PATH" --data-urlencode "segment_path=$MTX_SEGMENT_PATH" --data-urlencode "duration=$MTX_SEGMENT_DURATION"
runOnNotReady: curl -X POST "https://nyone.app/internal/mediamtx/not-ready?secret=change-this-secret" --data-urlencode "path=$MTX_PATH" --data-urlencode "source_type=$MTX_SOURCE_TYPE" --data-urlencode "source_id=$MTX_SOURCE_ID"
runOnRecordSegmentComplete: curl -X POST "https://nyone.app/internal/mediamtx/recording-complete?secret=change-this-secret" --data-urlencode "path=$MTX_PATH" --data-urlencode "segment_path=$MTX_SEGMENT_PATH" --data-urlencode "duration=$MTX_SEGMENT_DURATION"
paths:
all_others:

View File

@@ -2,9 +2,9 @@
These configs match the current project domains:
- App: `https://nyone.net`
- App: `https://nyone.app`
- HLS playback: `https://nyone-hls.net`
- OBS ingest: `rtmp://nyone.net:19935`
- OBS ingest: `rtmp://nyone.app:19935`
MediaMTX stays private on local ports:
@@ -29,14 +29,14 @@ sudo systemctl reload nginx
Make sure DNS points both hostnames to the server:
- `nyone.net`
- `nyone.app`
- `nyone-hls.net`
The Laravel `.env` should use:
```env
APP_URL=https://nyone.net
STREAMING_RTMP_INGEST_URL=rtmp://nyone.net:19935
APP_URL=https://nyone.app
STREAMING_RTMP_INGEST_URL=rtmp://nyone.app:19935
STREAMING_HLS_PUBLIC_URL=https://nyone-hls.net
MEDIAMTX_SHARED_SECRET=change-this-secret
```

View File

@@ -1,11 +1,11 @@
# Laravel application vhost for nyone.net.
# Laravel application vhost for nyone.app.
# Copy to /etc/nginx/sites-available/nyone-app.conf and symlink into sites-enabled.
# Adjust root and fastcgi_pass for your server.
server {
listen 80;
listen [::]:80;
server_name nyone.net;
server_name nyone.app;
return 301 https://$host$request_uri;
}
@@ -13,13 +13,13 @@ server {
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name nyone.net;
server_name nyone.app;
root /var/www/nyone/public;
index index.php;
ssl_certificate /etc/letsencrypt/live/nyone.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/nyone.net/privkey.pem;
ssl_certificate /etc/letsencrypt/live/nyone.app/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/nyone.app/privkey.pem;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;

View File

@@ -1,5 +1,5 @@
# HLS reverse proxy for MediaMTX.
# Browser origin is https://nyone.net, while playback URLs point here:
# Browser origin is https://nyone.app, while playback URLs point here:
# https://nyone-hls.net/<channel-slug>/index.m3u8
server {

View File

@@ -9,6 +9,7 @@ PUBLIC_SCHEME=https
APP_DOMAIN=nyone.example.com
HLS_DOMAIN=nyone-hls.example.com
APP_HTTP_PORT=80
HLS_HTTP_PORT=127.0.0.1:8888
RTMP_PUBLIC_PORT=1935
APP_NAME=Nyone

View File

@@ -1,6 +1,6 @@
# Nyone Dockerized Deployment
# Nyone Docker Bundle Deployment
This folder is a self-contained deployment bundle. A server only needs the `dockerized/` directory; the application repository is cloned later into `runtime/source` by `scripts/deploy.sh`.
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
@@ -14,33 +14,35 @@ This folder is a self-contained deployment bundle. A server only needs the `dock
Default ports use the services' normal defaults:
- HTTP app/HLS proxy: `80`
- HTTP app proxy: `80`
- RTMP ingest: `1935`
- MediaMTX HLS inside Docker: `8888`
- MediaMTX HLS on localhost: `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.
HTTP and RTMP are published by default. HLS is published only on `127.0.0.1:8888` by default so host Nginx can proxy `HLS_DOMAIN` directly to MediaMTX without exposing the HLS port publicly.
## 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.
If a host-level reverse proxy already uses port `80`, set `APP_HTTP_PORT` to another local port for the app and keep `HLS_HTTP_PORT` on a local MediaMTX port for HLS.
## First Deployment
```bash
cd dockerized
cd docker-bundle
cp .env.example .env
nano .env
chmod +x scripts/deploy.sh
chmod +x scripts/backup.sh
./scripts/deploy.sh
```
@@ -69,13 +71,14 @@ The deploy script will:
## Runtime Configuration
This bundle does not use the cloned repository's `.env` file for production settings. Docker Compose passes configuration from `dockerized/.env` into the containers.
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`.
- `HLS_HTTP_PORT`: host address published by MediaMTX HLS, default `127.0.0.1:8888`.
- `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.
@@ -85,7 +88,7 @@ The MediaMTX config is Docker-owned at `docker/mediamtx/mediamtx.yml.template`;
## Operations
Run commands from the `dockerized/` directory.
Run commands from the `docker-bundle/` directory.
```bash
docker compose --env-file .env -f docker-compose.yml ps
@@ -101,6 +104,29 @@ Redeploy after new Git changes:
./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
@@ -111,7 +137,7 @@ Do not remove volumes unless you intend to delete database, Redis, and stored ap
## 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.
Forward `APP_DOMAIN` to the Apache container's published HTTP port. Forward `HLS_DOMAIN` directly to the MediaMTX HLS localhost port. Preserve the `Host` header for the Laravel app 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.
@@ -121,16 +147,17 @@ Use this pattern when Nginx runs on the host and handles public HTTP/HTTPS. In t
```env
APP_HTTP_PORT=127.0.0.1:8080
HLS_HTTP_PORT=127.0.0.1:8888
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.
Run `./scripts/deploy.sh` after changing `.env`, then point Nginx app traffic at `http://127.0.0.1:8080` and HLS traffic at `http://127.0.0.1:8888`. Replace the example domains and certificate paths before enabling the config.
```nginx
# /etc/nginx/sites-available/nyone-dockerized.conf
# /etc/nginx/sites-available/nyone-docker-bundle.conf
server {
listen 80;
@@ -178,7 +205,11 @@ server {
ssl_certificate_key /etc/letsencrypt/live/hls.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8080;
if ($request_method = OPTIONS) {
return 204;
}
proxy_pass http://127.0.0.1:8888;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -190,6 +221,10 @@ server {
proxy_request_buffering off;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
add_header Access-Control-Allow-Origin "https://app.example.com" always;
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
add_header Access-Control-Allow-Headers "Range, Origin, Accept, Content-Type" always;
add_header Access-Control-Expose-Headers "Content-Length, Content-Range" always;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
}
}
@@ -198,9 +233,21 @@ server {
Enable and validate on the server:
```bash
sudo ln -s /etc/nginx/sites-available/nyone-dockerized.conf /etc/nginx/sites-enabled/nyone-dockerized.conf
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.
## HLS CORS Check
Host Nginx owns the public HLS CORS headers. MediaMTX is configured with `hlsAllowOrigins: []` so the response does not include duplicate `Access-Control-Allow-Origin` headers after proxying.
Check the deployed headers with the exact app origin:
```bash
curl -I -H "Origin: https://app.example.com" "https://hls.example.com/channel/index.m3u8"
```
The response should contain one `Access-Control-Allow-Origin` header matching the app origin. If two `Access-Control-Allow-Origin` headers appear, rebuild the MediaMTX container from this bundle and reload Nginx.

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

@@ -0,0 +1,79 @@
---
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 the app and HLS are served by different host-level Nginx 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`.
- Host Nginx should proxy `HLS_DOMAIN` directly to the locally published MediaMTX HLS port.
- `docker/mediamtx/entrypoint.sh` renders the MediaMTX template at container start.
## Port Defaults
- Apache published HTTP: `80`.
- MediaMTX RTMP: `1935`.
- MediaMTX HLS local publish/internal: `8888`.
- MediaMTX API internal: `9997`.
- PostgreSQL internal: `5432`.
- Redis internal: `6379`.
Publish Apache HTTP and RTMP by default, and bind MediaMTX HLS to `127.0.0.1:8888` for host Nginx. Keep the MediaMTX 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 the app hostname only. Nginx should proxy HLS directly to the locally published MediaMTX HLS port.
- Recommend `APP_HTTP_PORT=127.0.0.1:8080` when Nginx owns host ports `80` and `443`.
- Recommend `HLS_HTTP_PORT=127.0.0.1:8888` so HLS is reachable by host Nginx but not publicly exposed as a raw port.
- Include `X-Forwarded-Proto https`, `X-Forwarded-Port 443`, `X-Forwarded-Host`, `X-Real-IP`, and `X-Forwarded-For` in proxy examples.
- Let host Nginx own public HLS CORS headers. Keep `hlsAllowOrigins: []` in the MediaMTX template so proxied HLS responses do not contain duplicate `Access-Control-Allow-Origin` values.
- 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,492 @@
# 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`
- Docker MediaMTX HLS is private on `127.0.0.1:8888`
- 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`, MediaMTX HLS is bound to `127.0.0.1:8888`, and 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
HLS_HTTP_PORT=127.0.0.1:8888
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 / {
if ($request_method = OPTIONS) {
return 204;
}
proxy_pass http://127.0.0.1:8888;
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 Access-Control-Allow-Origin "https://nyone.app" always;
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
add_header Access-Control-Allow-Headers "Range, Origin, Accept, Content-Type" always;
add_header Access-Control-Expose-Headers "Content-Length, Content-Range" always;
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 HLS CORS headers:
```bash
curl -I -H "Origin: https://nyone.app" "https://hls.nyone.app/<channel-slug>/index.m3u8"
```
The response should include exactly one `Access-Control-Allow-Origin: https://nyone.app` header. A `404` can be normal when the channel is not currently publishing, but duplicate `Access-Control-Allow-Origin` headers mean both Nginx and an upstream are setting CORS; rebuild the MediaMTX container from this bundle and reload Nginx.
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

@@ -59,7 +59,6 @@ x-app-environment: &app-environment
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}
@@ -142,13 +141,12 @@ services:
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"
- "${HLS_HTTP_PORT:-127.0.0.1:8888}:8888"
volumes:
- app-storage:/var/www/html/storage
depends_on:

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

View File

@@ -15,7 +15,7 @@ require_env() {
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."
fail "$key is required. Run docker-bundle/scripts/deploy.sh or set it in docker-bundle/.env."
fi
}
@@ -41,9 +41,9 @@ artisan() {
}
render_apache_config() {
export APP_DOMAIN HLS_DOMAIN HLS_INTERNAL_PORT APP_URL
export APP_DOMAIN
envsubst '${APP_DOMAIN} ${HLS_DOMAIN} ${HLS_INTERNAL_PORT} ${APP_URL}' \
envsubst '${APP_DOMAIN}' \
< /etc/apache2/sites-available/nyone.conf.template \
> /etc/apache2/sites-available/000-default.conf
}
@@ -61,6 +61,16 @@ prepare_laravel_paths() {
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
@@ -93,7 +103,7 @@ if [[ "${WAIT_FOR_SERVICES:-true}" == "true" ]]; then
fi
prepare_laravel_paths
artisan storage:link --force >/dev/null || true
ensure_public_storage_link
cache_laravel_bootstrap
case "$role" in
@@ -121,7 +131,6 @@ case "$role" in
;;
viewer-sync)
exec gosu www-data php artisan streaming:sync-viewer-counts \
--isolated \
--interval="${STREAMING_VIEWER_SYNC_INTERVAL:-2}"
;;
artisan)

View File

@@ -7,19 +7,17 @@ fail() {
}
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."
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
export MEDIAMTX_SHARED_SECRET RTMP_INTERNAL_PORT HLS_INTERNAL_PORT
envsubst '${APP_URL} ${HLS_PUBLIC_URL} ${MEDIAMTX_SHARED_SECRET} ${RTMP_INTERNAL_PORT} ${HLS_INTERNAL_PORT}' \
envsubst '${MEDIAMTX_SHARED_SECRET} ${RTMP_INTERNAL_PORT} ${HLS_INTERNAL_PORT}' \
< /etc/mediamtx/mediamtx.yml.template \
> /config/mediamtx.yml

View File

@@ -16,9 +16,7 @@ rtmpAddress: :${RTMP_INTERNAL_PORT}
hls: yes
hlsAddress: :${HLS_INTERNAL_PORT}
hlsVariant: mpegts
hlsAllowOrigins:
- ${APP_URL}
- ${HLS_PUBLIC_URL}
hlsAllowOrigins: []
pathDefaults:
record: yes
@@ -32,4 +30,3 @@ pathDefaults:
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

@@ -158,7 +158,7 @@ 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."
fail "APP_DOMAIN and HLS_DOMAIN must be different because this deployment uses separate app and HLS hostnames."
fi
GIT_REF="${GIT_REF:-main}"

View File

@@ -1,70 +0,0 @@
---
name: dockerized-deployment
description: Work on the Nyone Dockerized deployment bundle under dockerized/. 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 Dockerized Deployment
## Core Rules
- Keep deployment changes inside `dockerized/` unless the user explicitly requests application code changes.
- Treat `dockerized/.env` and `dockerized/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.
- `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`.
- Keep deploy script behavior idempotent: repeat runs should update the Git checkout, rebuild, migrate, and restart services without deleting volumes.
- 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]" dockerized -n
```
If Docker is available and the user allows non-deploy validation, validate only the Compose config:
```bash
docker compose --env-file dockerized/.env.example -f dockerized/docker-compose.yml config
```
Do not start containers during local verification unless the user explicitly asks for runtime testing.

View File

@@ -1,75 +0,0 @@
# 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"]

View File

@@ -1,43 +0,0 @@
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

@@ -157,16 +157,16 @@ admin@example.com / password
Current `.env` values relevant to streaming:
```env
APP_URL=https://nyone.net
STREAMING_RTMP_INGEST_URL=rtmp://nyone.net:19935
APP_URL=https://nyone.app
STREAMING_RTMP_INGEST_URL=rtmp://nyone.app:19935
STREAMING_HLS_PUBLIC_URL=https://nyone-hls.net
MEDIAMTX_SHARED_SECRET=change-this-secret
```
MediaMTX current files:
- `deploy/mediamtx.yml`: currently points auth/hooks to `https://nyone.net`.
- `deploy/mediamtx.home.yml`: points auth/hooks to `https://nyone.net`.
- `deploy/mediamtx.yml`: currently points auth/hooks to `https://nyone.app`.
- `deploy/mediamtx.home.yml`: points auth/hooks to `https://nyone.app`.
MediaMTX ports currently configured:
@@ -176,7 +176,7 @@ MediaMTX ports currently configured:
OBS should use:
```text
Server: rtmp://nyone.net:19935
Server: rtmp://nyone.app:19935
Stream Key: <channel-slug>?token=<generated-stream-key>
```