# Installing OpenStore on a LAMP VPS

Plain PHP 8 + MySQL. No Composer, no npm, no CDN. Upload the files, point a
vhost at `public/`, open `/install/index.php` once.

---

## 1. What the server needs

| | Required | Notes |
|---|---|---|
| PHP | 8.0 or newer | 8.1/8.2 recommended |
| Extensions | `pdo_mysql`, `mbstring`, `json` | `gd` for thumbnails, `fileinfo` for upload checks, `curl` only when you add a payment gateway |
| Database | MySQL 5.7+ / 8.x, or MariaDB 10.3+ | InnoDB, `utf8mb4` |
| Web server | Apache with `mod_rewrite` | nginx config is in section 6 |
| `upload_max_filesize` | 10M+ | product photos |
| `post_max_size` | 12M+ | must be larger than the upload limit |
| `memory_limit` | 256M | the app raises it at runtime when it is allowed to |
| `max_execution_time` | 300 | image resizing on bulk import |

Check on the server:

```bash
php -v
php -m | grep -E 'pdo_mysql|mbstring|gd|fileinfo|curl'
```

Install what is missing (Debian/Ubuntu):

```bash
sudo apt update
sudo apt install -y php8.2-cli php8.2-fpm php8.2-mysql php8.2-gd php8.2-mbstring php8.2-curl apache2 mysql-server
sudo a2enmod rewrite headers deflate expires
```

On CentOS/RHEL/Alma the packages are `php php-mysqlnd php-gd php-mbstring php-fpm httpd mariadb-server`.

---

## 2. Upload the files

Copy the whole `store/` folder to the server, then set the owner to the web user.

```bash
sudo mkdir -p /var/www/openstore
sudo cp -r store/* /var/www/openstore/
sudo chown -R www-data:www-data /var/www/openstore     # apache: apache on RHEL
```

Layout after upload:

```
/var/www/openstore
├── app/          code (blocked from the web by app/.htaccess)
├── database/     schema.sql, seed.sql
├── public/       the only folder that should be a document root
│   ├── assets/   css, js, images
│   ├── install/  the installer - delete after use
│   ├── uploads/  everything you upload from the panel
│   └── index.php
└── storage/      logs, cache, database backups
```

---

## 3. Permissions

Only three paths need to be writable by PHP. Keep everything else read-only.

```bash
cd /var/www/openstore
sudo find . -type f -exec chmod 644 {} \;
sudo find . -type d -exec chmod 755 {} \;
sudo chmod -R 775 app/config public/uploads storage
sudo chown -R www-data:www-data app/config public/uploads storage
```

`app/config` must be writable **only while the installer runs**. After the
install finishes:

```bash
sudo chmod 640 app/config/config.php
```

---

## 4. Create the database

The installer can create the database itself if the MySQL user has
`CREATE` rights. Otherwise do it now:

```bash
sudo mysql -u root -p
```

```sql
CREATE DATABASE openstore CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'openstore'@'localhost' IDENTIFIED BY 'a-long-random-password';
GRANT ALL PRIVILEGES ON openstore.* TO 'openstore'@'localhost';
FLUSH PRIVILEGES;
```

---

## 5. Point Apache at `public/`

```apache
<VirtualHost *:80>
    ServerName yourshop.com
    ServerAlias www.yourshop.com
    DocumentRoot /var/www/openstore/public

    <Directory /var/www/openstore/public>
        AllowOverride All
        Require all granted
        Options -Indexes -MultiViews
    </Directory>

    # Belt and braces: these folders must never be served.
    <Directory /var/www/openstore/app>
        Require all denied
    </Directory>
    <Directory /var/www/openstore/storage>
        Require all denied
    </Directory>

    ErrorLog  ${APACHE_LOG_DIR}/openstore-error.log
    CustomLog ${APACHE_LOG_DIR}/openstore-access.log combined
</VirtualHost>
```

```bash
sudo a2ensite openstore && sudo systemctl reload apache2
```

**`DocumentRoot` must be `.../public`.** If your panel will not let you change
the document root, the project-level `.htaccess` rewrites everything into
`public/` for you; in that case URLs look like `https://yourshop.com/public/shop`.
Set the Store URL in the installer accordingly, or move the contents of
`public/` up one level and edit `public/index.php` to require
`../app/bootstrap.php` (it already does).

---

## 6. nginx instead of Apache

`.htaccess` files do nothing on nginx, so the equivalent goes in the server
block. Uploads are still protected because `/uploads/` disables PHP execution.

```nginx
server {
    listen 80;
    server_name yourshop.com;
    root /var/www/openstore/public;
    index index.php;

    client_max_body_size 12m;

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

    location /uploads/ {
        location ~ \.php$ { deny all; }
    }

    location ~ /\. { deny all; }
    location ~* \.(env|ini|log|sql|bak|old)$ { deny all; }

    location ~ ^/index\.php(/|$) {
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }
}
```

---

## 7. Run the installer

Open `https://yourshop.com/install/index.php`.

1. **Server check** - everything required must be green. Orange rows are
   recommendations (GD, fileinfo, upload size) and install can still continue.
2. **Database** - host, port, name, user, password. The database is created if
   it does not exist.
3. **Store** - name, tagline, URL, timezone, currency. Everything here is
   editable later in *Settings*; nothing is permanent.
4. **Admin account** - name, email, password (8+ characters). Tick *Load sample
   catalogue, pages and blog* if you want the store to look filled in while you
   learn the panel. Untick it for an empty shop.

The installer writes `app/config/config.php`, creates every table from
`database/schema.sql`, stores all ~250 settings in the `settings` table, loads
`database/seed.sql` when asked, and creates the admin user.

**Then delete the installer:**

```bash
sudo rm -rf /var/www/openstore/public/install
```

---

## 8. After installing

- **Admin panel:** `https://yourshop.com/admin`
- **Storefront:** `https://yourshop.com/`
- Add HTTPS with certbot, then set the Store URL in *Settings → General* to the
  `https://` version:
  ```bash
  sudo apt install certbot python3-certbot-apache
  sudo certbot --apache -d yourshop.com -d www.yourshop.com
  ```
- Set the admin email, currency and GSTIN in *Settings*, then your shipping
  zones and the Cash on Delivery method in *Shipping* and *Payments*.
- Upload a logo and a share image (*Appearance* and *Settings → SEO*).
- Build the header menu in *Menus*, the homepage blocks in *Homepage sections*.
- Turn on a daily database backup:
  ```bash
  mkdir -p /var/backups/openstore
  (crontab -l 2>/dev/null; echo '20 3 * * * mysqldump -u openstore -pPASSWORD openstore | gzip > /var/backups/openstore/$(date +\%F).sql.gz') | crontab -
  ```

### Scheduled maintenance (`public/cron.php`)

`public/cron.php` is the housekeeping task. It expires coupons, starts and
retires slides on their scheduled dates, deletes abandoned guest carts,
auto-cancels unpaid orders after the grace period you set, and prunes the log
tables. Run it once an hour:

```bash
(crontab -l 2>/dev/null; echo '5 * * * * php /var/www/openstore/public/cron.php') | crontab -
```

The same file can be hit over HTTP from a hosting control panel's "scheduled
task" feature - `https://yourshop.com/cron.php?token=...` - but that is only
safe once you paste a long random secret into *Settings → Performance &
Security → Cron token*. With an empty token every HTTP call is refused with
403, so the URL never becomes a way for a stranger to cancel your orders. The
CLI run ignores the token because only the shell on your own server can call it.

Three settings drive it and all three are in the same group:
*Auto-cancel unpaid after (days)*, *Guest cart cleanup (days)* and *Log pruning
(days)*. A zero in the auto-cancel setting leaves unpaid orders alone.

---

## 9. If something does not work

| Symptom | Cause and fix |
|---|---|
| Blank white page | `app/config/config.php` has a syntax error, or a fatal is being hidden. Set `'debug' => true` in that file, reload, then look at `storage/logs/php-error.log`. Turn debug off again afterwards. |
| Installer page is blank | PHP version below 8.0. `php -v`. |
| `Database connection failed` | Wrong credentials in `app/config/config.php`, or MySQL is on another host/port. Test with `mysql -h HOST -u USER -p DBNAME`. |
| Every page except the home page 404s | `mod_rewrite` off, or `AllowOverride None`. Run `sudo a2enmod rewrite`, set `AllowOverride All` on the `public` directory, reload Apache. |
| 403 on upload, "Upload directory is not writable" | `sudo chown -R www-data:www-data public/uploads && sudo chmod -R 775 public/uploads` |
| Thumbnails look broken | `gd` is not installed: `apt install php8.2-gd`, then restart PHP-FPM/Apache. |
| Large uploads fail silently | Raise `upload_max_filesize` and `post_max_size` in `php.ini`, then restart the web server. |
| Emails never arrive | The `mail()` driver is often blocked on VPS providers. Set *Settings → Email* to SMTP with your provider's credentials and use *Send test* in the email templates screen. |
| Site stuck in "maintenance" | Log in at `/admin` (admins always see the site), then untick *Maintenance mode* in *Settings → Performance & Security*. |
| Changes do not appear | Clear the cache: *Tools → Clear cache*, and check a page-level cache (LiteSpeed/WordPress-style plugins) is not sitting in front. |

---

## 10. Re-running setup

Setup is safe to repeat: tables use `CREATE TABLE IF NOT EXISTS`, settings rows
are upserted without touching values you edited, and the seed file uses
`ON DUPLICATE KEY UPDATE`. Deleting a stored row in the panel really does
delete it, so a re-run will not bring it back.

To reopen the installer after it locked itself, delete
`public/install/DONE.txt` - or, better, keep the folder deleted and edit
settings from the admin panel instead.
