# Deploying SiteCraft to a VPS

This guide sets up SiteCraft on a fresh **Ubuntu 24.04** server with **Caddy** (TLS for your platform and every merchant domain), **PHP 8.3-FPM** and **MySQL 8**. Everything runs on one machine. Allow about an hour.

All config files referred to below are in the `deploy/` folder of the release.

| You need | Example used below |
|---|---|
| A VPS with 2 GB RAM or more, root/sudo access | public IPv4 `203.0.113.10` |
| A domain for the platform | `example.com` |
| An SMTP account for email | Amazon SES, Zoho, Brevo… |
| A Razorpay account (to sell plans) | live keys + webhook secret |

---

## 1. DNS

Create these records at your DNS provider (TTL 300 is fine while you set up):

| Name | Type | Value | Why |
|---|---|---|---|
| `example.com` | A | `203.0.113.10` | marketing/sign-in |
| `app.example.com` | A | `203.0.113.10` | dashboard and admin |
| `*.example.com` | A | `203.0.113.10` | every store: `shop.example.com` |
| `stores.example.com` | A | `203.0.113.10` | merchants point their own domain's CNAME here |

If you send email from `example.com`, add your provider's **SPF**, **DKIM** and a **DMARC** record too, or emails will land in spam.

## 2. Server basics

```bash
sudo apt update && sudo apt -y upgrade
sudo adduser deploy && sudo usermod -aG sudo deploy        # work as this user, not root
sudo ufw allow OpenSSH && sudo ufw allow 80,443/tcp && sudo ufw allow 443/udp && sudo ufw enable
sudo timedatectl set-timezone Asia/Kolkata
sudo apt -y install unattended-upgrades fail2ban unzip curl
```

Use SSH keys and set `PasswordAuthentication no` in `/etc/ssh/sshd_config`.

## 3. PHP 8.3, MySQL and Caddy

```bash
sudo apt -y install php8.3-fpm php8.3-cli php8.3-mysql php8.3-intl php8.3-mbstring php8.3-curl \
                    php8.3-gd php8.3-xml php8.3-zip php8.3-opcache mysql-server
sudo cp deploy/php/99-sitecraft.ini /etc/php/8.3/fpm/conf.d/
sudo cp deploy/php/99-sitecraft.ini /etc/php/8.3/cli/conf.d/
sudo cp deploy/mysql/99-sitecraft.cnf /etc/mysql/mysql.conf.d/
sudo systemctl restart php8.3-fpm mysql

# Caddy (official repository)
sudo apt -y install debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt -y install caddy
```

Secure MySQL and create the database:

```bash
sudo mysql_secure_installation
sudo mysql <<'SQL'
CREATE DATABASE sitecraft CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'sitecraft'@'localhost' IDENTIFIED BY 'a-long-random-password';
GRANT ALL PRIVILEGES ON sitecraft.* TO 'sitecraft'@'localhost';
SQL
```

## 4. Install the application

The release layout keeps your settings, logs and uploaded images outside the code, so updates are a folder switch:

```
/var/www/releases/<date>/       one folder per release
/var/www/sitecraft  →  the current release (symlink; Caddy points here)
/var/www/shared/.env            settings
/var/www/shared/writable/       logs, cache, sessions, backups
/var/www/shared/media/          merchants' uploaded images
```

```bash
sudo mkdir -p /var/www/shared
sudo cp deploy/env.production.example /var/www/shared/.env
sudo nano /var/www/shared/.env          # fill in every value marked CHANGE (leave encryption.key empty for now)
sudo deploy/scripts/deploy.sh sitecraft-v1.0.zip
```

The deploy script unpacks the release, links the shared folders, sets permissions, runs database migrations, switches the symlink, reloads PHP and runs the checks. Then, **once**:

```bash
cd /var/www/sitecraft
sudo -u www-data php spark key:generate      # writes encryption.key into /var/www/shared/.env
sudo -u www-data php spark db:seed DatabaseSeeder   # plans, roles, templates, email templates, first admin
sudo nano /var/www/shared/.env               # now remove ADMIN_PASSWORD
```

> **Keep a copy of `encryption.key`** in your password manager. Merchants' Razorpay secrets are encrypted with it; if it's lost or changed they must re-enter their keys.

Do **not** run the `DemoSeeder` in production (it creates demo stores with known passwords).

## 5. Caddy (HTTPS for everything)

```bash
sudo cp deploy/caddy/Caddyfile /etc/caddy/Caddyfile
sudo sed -i 's/example\.com/yourdomain.com/g; s/ops@yourdomain\.com/you@yourdomain.com/' /etc/caddy/Caddyfile
sudo mkdir -p /var/log/caddy && sudo chown caddy: /var/log/caddy
sudo caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy
```

How certificates work:
- `app.example.com` and `example.com` get normal certificates at start-up.
- Store subdomains and merchants' own domains get a certificate the first time someone visits. Before asking Let's Encrypt, Caddy calls SiteCraft's `/__sc/tls-check`, which approves **only** hosts that belong to a store — nobody can make your server request certificates for random domains.
- Let's Encrypt allows 50 new certificates per week for `example.com`. If you expect more than ~40 new stores a week, switch store subdomains to one **wildcard certificate** (commented block at the end of the Caddyfile; needs a Caddy build with your DNS provider's plugin).

Prefer Nginx? `deploy/nginx/` has a working config for the platform and all store subdomains (wildcard certificate via certbot). Merchant domains then need a certificate each (`certbot --nginx -d shop.merchant.com`) once the dashboard shows them as connected — or keep Caddy just for those.

## 6. Scheduled jobs, logs, intrusion protection

```bash
sudo cp deploy/cron/sitecraft /etc/cron.d/sitecraft
sudo cp deploy/logrotate/sitecraft /etc/logrotate.d/sitecraft
sudo cp deploy/fail2ban/filter.d/sitecraft.conf /etc/fail2ban/filter.d/
sudo cp deploy/fail2ban/jail.d/sitecraft.local /etc/fail2ban/jail.d/
sudo systemctl restart fail2ban && sudo fail2ban-client status sitecraft
```

| Job | When | What |
|---|---|---|
| `subscriptions:process` | hourly | trials, renewals, grace periods, expiry, reminder emails |
| `domains:verify` | every 10 min | checks merchants' DNS (ownership, then routing) |
| `orders:expire-unpaid` | every 5 min | releases stock held by unpaid online orders; housekeeping |
| `backup:run` | nightly | database + uploaded images into `writable/backups`, keeps 14 |

Each job leaves a heartbeat. *Admin → Dashboard* shows when each last ran, and `sitecraft:doctor` fails if one is late.

## 7. Check everything

```bash
cd /var/www/sitecraft && sudo -u www-data php spark sitecraft:doctor
curl -s http://127.0.0.1:8081/health      # {"status":"ok",...}
```

Then in a browser:
0. `https://app.example.com/` shows the public marketing site (home, features, templates, pricing). Search engines use `app.baseURL` as the canonical address for it — if you'd rather have `https://example.com/` indexed, set `app.baseURL` to that (the dashboard then lives there too; `app.example.com` keeps working).
1. `https://app.example.com/login` → sign in as the admin from `.env`, set a new password when asked.
2. *Admin → Settings*: support email, legal pages, etc. *Admin → Plans*: check prices.
3. Register a test merchant (use a real inbox — emails must arrive), create a website, publish it, open `https://<name>.example.com`.
4. Add a custom domain to the test store, create the DNS records it shows, wait for **Connected**, open it over HTTPS.

## 8. Payments (Razorpay)

**Your plans** — in the Razorpay dashboard (live mode):
1. *Account & Settings → API keys*: put the key id/secret into `.env` (`razorpay.keyId`, `razorpay.keySecret`).
2. *Webhooks → Add*: URL `https://app.example.com/webhooks/razorpay/platform`, events `payment.captured`, `payment.failed`, `order.paid`, a strong secret → `razorpay.webhookSecret`.
3. `sudo systemctl reload php8.3-fpm`, then buy the cheapest plan with a real card and refund it from Razorpay.

**Merchants' stores** use their own Razorpay accounts: they enter keys in *Payments → Razorpay settings*, which also shows the webhook URL for their store. Money never passes through your account.

Keep `razorpay.simulator = false`. (The simulator is also disabled automatically when `CI_ENVIRONMENT = production`.)

## 9. Backups — and getting them off the server

`backup:run` writes `writable/backups/db-*.sql.gz` and `media-*.tar.gz`. A backup on the same disk doesn't survive losing the server, so copy them away nightly, e.g. with rclone to any S3-compatible bucket:

```bash
sudo apt -y install rclone && sudo rclone config        # create a remote called "offsite"
echo '15 3 * * * root rclone copy /var/www/shared/writable/backups offsite:sitecraft-backups --max-age 48h' | sudo tee /etc/cron.d/sitecraft-offsite
```

**Restore** (test this once before you need it):

```bash
gunzip -c db-20261110-023000.sql.gz | mysql -u sitecraft -p sitecraft
sudo tar -xzf media-20261110-023000.tar.gz -C /var/www/shared/    # recreates media/
sudo -u www-data php spark cache:clear
```

Also back up `/var/www/shared/.env` (contains the encryption key) somewhere safe.

## 10. Updating

```bash
sudo deploy/scripts/deploy.sh sitecraft-v1.1.zip
```

The script backs up the database before migrating, keeps the last 5 releases and clears the store page cache. To roll back a release **without** database changes: `sudo ln -sfn /var/www/releases/<previous> /var/www/sitecraft && sudo systemctl reload php8.3-fpm`. If the release migrated the database, restore the pre-deploy backup as well.

After editing `.env`: `sudo systemctl reload php8.3-fpm` (OPcache keeps compiled files until reload).

## 11. Monitoring

- Point an uptime monitor (UptimeRobot, Better Stack…) at `https://app.example.com/health` — 200 when the database and storage work, 503 otherwise.
- Errors and warnings go to `writable/logs/log-YYYY-MM-DD.log`; failed sign-ins appear as `SECURITY failed-login` lines (fail2ban reads them).
- MySQL slow queries (over 1 s) go to `/var/log/mysql/slow.log`.
- Run `php spark sitecraft:doctor` after every change to the server.

## 12. Growing beyond one server

SiteCraft runs on one VPS for a long time (rendered store pages are cached; a 2 GB VPS serves cached pages in under 10 ms). When you outgrow it:

1. Move MySQL to its own server or a managed database.
2. Use **Redis** for cache and sessions on all app servers (`cache.handler = redis`, `session.driver = 'CodeIgniter\Session\Handlers\RedisHandler'`, `session.savePath = 'tcp://10.0.0.5:6379'`).
3. Share `public/media` between app servers (NFS, or object storage mounted with rclone/s3fs).
4. Run the cron jobs on **one** server only (they also lock themselves).
5. Put the servers behind a load balancer that keeps the `Host` header; list its IPs in `app/Config/App.php → $proxyIPs` so visitors' real IPs are used for rate limits and logs.

## Troubleshooting

| Symptom | Fix |
|---|---|
| Every store shows the sign-in page | The request's Host isn't reaching PHP. Caddy: keep `php_fastcgi` as in the template. Nginx: `fastcgi_param HTTP_HOST $host;`. Check `platform.baseDomain`. |
| Store pages show old content | `sudo -u www-data php spark cache:clear` |
| Custom domain stays "Waiting for DNS" | `dig TXT _sitecraft-verify.shop.merchant.com` must show the token; `dig shop.merchant.com` must point to `stores.example.com` / your IP. Check `writable/logs/cron.log` for `domains:verify`. |
| Certificate not issued for a store | `journalctl -u caddy` — check the ask URL works: `curl "http://127.0.0.1:8081/__sc/tls-check?domain=shop.example.com"` → `ok`. |
| Emails not arriving | Check SMTP settings and your provider's sending logs; `doctor` says whether SMTP is configured. Without SMTP, emails are only saved to `writable/mail/`. |
| "The action you requested is not allowed" | CSRF: the form was open too long, or cookies are blocked. `cookie.secure = true` requires HTTPS. |
| Uploads fail | `upload_max_filesize`/`post_max_size` (deploy/php/99-sitecraft.ini) and `request_body max_size` in Caddy; `public/media` writable by www-data. |
| 502 Bad Gateway | PHP-FPM not running or socket path differs: `systemctl status php8.3-fpm`, check the socket in the Caddyfile. |
