Files
nyone/docker-bundle/UBUNTU_VPS_NYONE_APP_DEPLOYMENT.md
Meghdad b3bb160b90
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
Splitting MediaRTX from Apache in docker-bundle
2026-05-20 00:59:19 +03:30

12 KiB

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:

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:

dig +short nyone.app
dig +short hls.nyone.app

2. SSH into the VPS

ssh root@<VPS_PUBLIC_IPV4>

Update the server:

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:

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:

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:

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:

mkdir -p /opt/nyone

From your local machine, copy the folder:

rsync -av --exclude '.env' --exclude 'runtime' docker-bundle/ root@<VPS_PUBLIC_IPV4>:/opt/nyone/docker-bundle/

Back on the VPS:

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:

nano /opt/nyone/docker-bundle/.env

Use these values for nyone.app:

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:

APP_KEY=
POSTGRES_PASSWORD=
MEDIAMTX_SHARED_SECRET=

If the repository is public, you can use an HTTPS clone URL instead:

GIT_REPOSITORY_URL=https://github.com/your-org/nyone.git

If GIT_REPOSITORY_URL is private, install a deploy key on the VPS:

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:

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:

ssh -T git@github.com

7. Run the Docker deployment

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:

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:

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:

mkdir -p /var/www/certbot

9. Create temporary Nginx config for SSL issuance

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:

certbot certonly --webroot \
    -w /var/www/certbot \
    -d nyone.app \
    -d hls.nyone.app

The certificate files should be under:

/etc/letsencrypt/live/nyone.app/fullchain.pem
/etc/letsencrypt/live/nyone.app/privkey.pem

10. Install final Nginx proxy config

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

certbot renew --dry-run

12. Verify the deployment

Check the app health endpoint:

curl -I https://nyone.app/up

Check container status:

cd /opt/nyone/docker-bundle
docker compose --env-file .env -f docker-compose.yml ps

Check database migrations:

docker compose --env-file .env -f docker-compose.yml exec app php artisan migrate:status

Run one viewer-count sync pass:

docker compose --env-file .env -f docker-compose.yml exec app php artisan streaming:sync-viewer-counts --once -v

Check HLS CORS headers:

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:

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:

OBS server: rtmp://nyone.app:1935
OBS stream key: <channel-slug>?token=<generated-stream-key>

Viewer playback URLs generated by Laravel should look like:

https://hls.nyone.app/<channel-slug>/index.m3u8

14. Redeploy after app changes

When the Git branch changes:

cd /opt/nyone/docker-bundle
./scripts/deploy.sh

Useful logs:

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:

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:

./scripts/backup.sh --stop-writers

The final archive is written to:

/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:

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:

./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