# ZyloVPN — Deployment

Two machine roles, deployed independently:

| Role | Carries | Needs inbound |
|---|---|---|
| **Application server** | Panel, API, database, queue | 80, 443 |
| **VPN node** | Customer traffic, WireGuard | WireGuard UDP only |

Nodes never accept inbound HTTP. They pull from the panel (docs/ARCHITECTURE.md §1.1), so you can add one behind NAT or a restrictive cloud firewall without opening anything.

---

## Part 1 — Application server

Ubuntu 24.04 LTS. Commands assume a non-root user with sudo.

### 1.1 Packages

```bash
sudo apt update && sudo apt upgrade -y
sudo apt install -y nginx mysql-server redis-server git unzip curl \
  php8.3-fpm php8.3-cli php8.3-mysql php8.3-redis php8.3-mbstring \
  php8.3-xml php8.3-curl php8.3-zip php8.3-bcmath php8.3-gd \
  php8.3-intl php8.3-sodium
```

`php8.3-sodium` is **required**, not optional — WireGuard keypairs are generated through libsodium. Verify:

```bash
php -m | grep -E '^(sodium|pdo_mysql|redis)$'
```

Composer:

```bash
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
```

Node (for building assets):

```bash
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
```

### 1.2 Database

```bash
sudo mysql_secure_installation
```

Create the database and a least-privilege user — the application never needs `SUPER`, `FILE`, or `GRANT`:

```bash
sudo mysql <<'SQL'
CREATE DATABASE zylovpn CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'zylovpn'@'localhost' IDENTIFIED BY 'CHANGE_ME_STRONG_PASSWORD';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES
  ON zylovpn.* TO 'zylovpn'@'localhost';
FLUSH PRIVILEGES;
SQL
```

### 1.3 Application

```bash
sudo mkdir -p /var/www/zylovpn
sudo chown -R $USER:www-data /var/www/zylovpn
cd /var/www/zylovpn

git clone <your-repo> .
composer install --no-dev --optimize-autoloader
npm ci && npm run build

cp .env.example .env
php artisan key:generate
```

Edit `.env`:

```ini
APP_ENV=production
APP_DEBUG=false
APP_URL=https://panel.zylovpn.com

DB_DATABASE=zylovpn
DB_USERNAME=zylovpn
DB_PASSWORD=CHANGE_ME_STRONG_PASSWORD

CACHE_STORE=redis
QUEUE_CONNECTION=redis

# Stays `database` in production. Customer-facing session management needs
# storage that can be listed per user and deleted row by row — see
# docs/ARCHITECTURE.md §2.1.
SESSION_DRIVER=database
```

> **`APP_DEBUG=false` is not optional.** With it on, any unhandled exception renders a stack trace containing environment variables — including `DB_PASSWORD` and `APP_KEY`. `APP_KEY` decrypts every stored WireGuard private key.

Migrate, seed and cache:

```bash
php artisan migrate --force
php artisan db:seed --force          # permissions, roles, settings — idempotent
php artisan config:cache
php artisan route:cache
php artisan view:cache
```

Create the first administrator:

```bash
php artisan tinker --execute="
\$a = App\Models\AdminUser::create([
  'name' => 'Your Name',
  'email' => 'you@example.com',
  'password' => 'a-long-passphrase-you-will-change',
  'is_active' => true,
]);
\$a->roles()->sync(App\Models\Role::where('slug','super-admin')->pluck('id'));
echo 'Created admin '.\$a->email.PHP_EOL;
"
```

Sign in at `/admin/login` and enable two-factor authentication immediately.

### 1.4 Permissions

```bash
sudo chown -R www-data:www-data /var/www/zylovpn/storage /var/www/zylovpn/bootstrap/cache
sudo find /var/www/zylovpn -type f -exec chmod 644 {} \;
sudo find /var/www/zylovpn -type d -exec chmod 755 {} \;
sudo chmod -R 775 /var/www/zylovpn/storage /var/www/zylovpn/bootstrap/cache

# .env holds APP_KEY and the database password. Nothing but PHP reads it.
sudo chown www-data:www-data /var/www/zylovpn/.env
sudo chmod 640 /var/www/zylovpn/.env
```

### 1.5 Nginx

`/etc/nginx/sites-available/zylovpn`:

```nginx
server {
    listen 80;
    server_name panel.zylovpn.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name panel.zylovpn.com;

    root /var/www/zylovpn/public;
    index index.php;

    # certbot fills these in
    ssl_certificate     /etc/letsencrypt/live/panel.zylovpn.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/panel.zylovpn.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;

    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;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    client_max_body_size 12M;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }

    # Nothing under these should ever be served, whatever the try_files rules do.
    location ~ /\.(?!well-known).* { deny all; }
    location ~ ^/(storage/framework|bootstrap/cache)/ { deny all; }
}
```

```bash
sudo ln -s /etc/nginx/sites-available/zylovpn /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
```

TLS:

```bash
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d panel.zylovpn.com
```

### 1.6 Queue worker

`/etc/systemd/system/zylovpn-worker.service`:

```ini
[Unit]
Description=ZyloVPN queue worker
After=network.target mysql.service redis-server.service

[Service]
User=www-data
Group=www-data
Restart=always
RestartSec=5
ExecStart=/usr/bin/php /var/www/zylovpn/artisan queue:work --sleep=3 --tries=3 --max-time=3600

[Install]
WantedBy=multi-user.target
```

```bash
sudo systemctl daemon-reload
sudo systemctl enable --now zylovpn-worker
```

### 1.7 Scheduler

```bash
sudo crontab -u www-data -e
```

```cron
* * * * * cd /var/www/zylovpn && php artisan schedule:run >> /dev/null 2>&1
```

This drives `zylo:mark-servers-offline` (every minute) and `zylo:reconcile-peer-counts` (hourly). Without it, dead nodes keep showing as Online in the panel — though they are still excluded from selection, which compares heartbeat timestamps at query time.

### 1.8 Application-server firewall

```bash
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw default deny incoming
sudo ufw --force enable
```

MySQL and Redis stay on localhost. Neither should ever be reachable from outside this host.

---

## Part 2 — VPN node

Ubuntu 24.04 LTS, root access.

### 2.1 Register the server in the panel first

The node needs a token that only the panel can issue.

1. **Admin → Locations → Add location** (skip if the city already exists).
2. **Admin → VPN Servers → Register server.** Enter the node's public IP and WireGuard port. It is created as *Provisioning* and will not receive customers yet.
3. **Copy the agent token.** It is shown once. Only its hash is stored — if you lose it, rotate to get a new one.
4. **Add an IP pool** on the server page, e.g. `10.10.0.0/24` with gateway `10.10.0.1`. Every usable address is written to the database at this point, which is what makes allocation collision-free.

> A node with no IP pool cannot be assigned peers and will never be selected. The agent detects this and declines to bring the interface up, rather than creating a tunnel that accepts handshakes and blackholes every packet.

### 2.2 Provision the node

Copy the `agent/` directory to the node, then:

```bash
sudo ./install.sh \
  --panel https://panel.zylovpn.com \
  --token zylo_node_xxxxxxxxxxxxxxxxxxxx
```

The script installs WireGuard and the agent, enables IP forwarding, configures NAT against the real default-route interface, opens only SSH and the WireGuard port in UFW, then **runs one agent cycle in the foreground before enabling the service**. If that cycle fails you get a readable error and nothing is enabled.

Options: `--interface wg0`, `--port 51820`, `--ssh-port 22`.

### 2.3 Verify

```bash
systemctl status zylo-agent
journalctl -u zylo-agent -f
wg show wg0
```

Within a few seconds the server should show **Online** in the panel, with matching desired and applied state hashes on its detail page.

### 2.4 What the node does not have

No inbound HTTP. No SSH key from the panel. No customer private keys — the panel sends only public keys and allowed IPs, so a compromised node cannot impersonate its users. Its only credential is one rotatable bearer token.

---

## Part 3 — Operations

### Adding capacity

Repeat Part 2. No application change, no restart, no configuration edit on the panel. A new node in an existing location starts receiving peers as soon as it heartbeats; the selector spreads new peers toward the least loaded node.

### Retiring a node

1. Admin → the server → **Mark Maintenance**. It stops receiving new peers immediately; existing tunnels keep working.
2. Wait for its peers to rotate, or revoke them from the customer's devices.
3. Remove the server once its peer count reaches zero. Removal is blocked while peers remain.

### Rotating an agent token

Rotate in the panel, then on the node:

```bash
sudo sed -i 's/^token = .*/token = zylo_node_NEW_TOKEN/' /etc/zylovpn/agent.conf
sudo systemctl restart zylo-agent
```

The node stops syncing between rotation and restart. Existing tunnels are unaffected — the data plane does not depend on the control plane.

### Deploying an application update

```bash
cd /var/www/zylovpn
php artisan down --render="errors::503"
git pull
composer install --no-dev --optimize-autoloader
npm ci && npm run build
php artisan migrate --force
php artisan config:cache && php artisan route:cache && php artisan view:cache
sudo systemctl restart zylovpn-worker
php artisan up
```

`queue:restart` alone is not enough after a code change — the worker must actually restart to load new class definitions.

### Backups

```bash
mysqldump --single-transaction --quick zylovpn | gzip > zylovpn-$(date +%F).sql.gz
```

Back up `.env` **separately and encrypted**. It holds `APP_KEY`, which decrypts every stored WireGuard private key. A database backup without `APP_KEY` cannot be restored into a working system; a database backup *with* `APP_KEY` beside it is a complete compromise if it leaks.

### Health checks

| Check | Command |
|---|---|
| App responding | `curl -fsS https://panel.zylovpn.com/up` |
| Queue running | `systemctl is-active zylovpn-worker` |
| Scheduler running | `sudo -u www-data crontab -l` |
| Node syncing | `journalctl -u zylo-agent --since -5m` |
| Peers on a node | `wg show wg0 \| grep -c peer` |

---

## Troubleshooting

**Node stays Provisioning.** The agent has never completed a heartbeat. Check `journalctl -u zylo-agent`; a 401 means the token is wrong or was rotated.

**Node shows Online, customers cannot reach the internet.** Almost always forwarding or NAT. Verify:

```bash
sysctl net.ipv4.ip_forward          # must be 1
sudo iptables -t nat -L POSTROUTING -n -v | grep MASQUERADE
```

**Desired and applied hashes never converge.** The agent is running but its sync is failing. Check the logs for an exception during `wg set` — usually a permissions problem or a missing `wireguard-tools`.

**Customer's config imports but never connects.** Check the server's public key in the panel matches `wg show wg0 public-key` on the node. A key that was pasted in by hand and mistyped produces exactly this.

**`No servers are available in that location`.** Every server there failed eligibility. On the server page, check status, the heartbeat age, load against capacity, and whether the IP pool has free addresses — these are independent ceilings and any one of them will exclude a node.
