# Setup & Deployment ## 1. Requirements - Shared webhosting with PHP **8.1 or newer**, `curl` and `openssl` extensions (both are standard), and Apache `.htaccess` support. - A **Hetzner Object Storage** bucket (any S3-compatible storage works). - No database, no Composer, no build step. ## 2. Hetzner Object Storage 1. In the [Hetzner Cloud Console](https://console.hetzner.cloud/) create a Bucket (e.g. location `fsn1`). Set visibility to **private** — visitors get access only through short-lived presigned URLs. 2. Create S3 credentials (Security → S3 credentials) and note the *access key* and *secret key*. No bucket **CORS** rule is needed: uploads are proxied through the webhost (same-origin) and gallery images load via `` presigned GET URLs, which browsers don't subject to CORS. Keep the bucket **private**. ## 2a. PHP upload limits Uploads stream through PHP one image per request, so the *total* gallery size is irrelevant — but each single image must fit the host's limits. Ensure `upload_max_filesize` and `post_max_size` are at least as large as your biggest original (e.g. 200M for RAW files); on shared hosting set these in `.user.ini` or `php.ini`: ```ini upload_max_filesize = 200M post_max_size = 200M ``` `max_execution_time` is lifted per upload request in code, but if your host caps it at the web-server level (e.g. Apache/FPM request timeout), raise that too for large files. The browser uploads several images at once (`uploads.concurrency`, default 3), which keeps the uplink busy while the webhost forwards earlier files to S3. Each one occupies a PHP worker for its whole S3 round trip, so on shared hosting with a tight per-site process limit, lower it: ```php 'concurrency' => 2, // or 1 to restore strictly serial uploads ``` Gallery order does not depend on this: files are stored in the order they were selected whatever value you set, and whatever order the uploads finish in. If uploads start failing with 503s under load, that limit is the first thing to check. ## 2b. Download-all archives Enabling downloads on a gallery builds one ZIP of it into S3, so visitors get a single file without the webhost ever serving the bytes. Two things to know: - **Storage.** The archive roughly doubles that gallery's S3 storage for as long as it exists. Disabling downloads deletes it again. - **Build traffic.** Building copies every photo S3 → webhost → S3 (about twice the gallery's size in webhost traffic), once per rebuild. It is split into slices of `archive.step_seconds` so no request approaches `max_execution_time` — the 60 s cap common on shared hosting is fine and needs no change. Rebuilds normally happen on their own: a page view dispatches `worker.php`, which runs a slice and then dispatches its successor until the archive is finished. If your host **blocks outbound HTTP to itself**, that chain cannot start, and archives instead advance one slice per page view. A real cron job removes the guesswork entirely — every 5 minutes is plenty: ``` */5 * * * * curl -s "https://www.example.com/worker.php?key=YOUR_WORKER_KEY" >/dev/null ``` The key is generated on first use and stored in `data/worker-key.json`; read it from there. Without a valid key the script returns a bare 404. An interrupted build always resumes where it stopped, and one abandoned for `archive.abandon_hours` (default 24) is aborted and restarted — which also releases the incomplete multipart upload S3 would otherwise keep billing for. ## 3. Configuration ```bash cp config/config.sample.php config/config.php cp config/credentials.sample.php config/credentials.php ``` Edit `config/config.php`: | Key | Meaning | | --- | --- | | `site.name` | Fallback site title | | `site.base_url` | Public base URL, used for gallery share links | | `site.timezone` | Timezone for expiry checks, e.g. `Europe/Berlin` | | `s3.endpoint` | `https://.your-objectstorage.com` | | `s3.region` | The location, e.g. `fsn1` | | `s3.bucket` | Bucket name | | `s3.access_key` / `s3.secret_key` | S3 credentials | | `s3.url_ttl` | Lifetime of presigned view URLs in seconds | | `uploads.thumb_size` | Longest edge of grid thumbnails (browser-generated) | | `uploads.resize_quality` | JPEG quality (0.0–1.0) for galleries that cap their upload resolution | The default admin login is `admin` / `changeme` — **change it in the admin Settings page immediately after the first login.** ## 4. Uploading to the webhost Upload the **contents of this folder** into your account's document root (usually `public_html/`, `htdocs/` or `www/`) via FTP/SFTP. The site's home page, `index.php`, sits directly in the document root — there is no separate web-root subfolder to configure. The application internals (`app/`, `config/`, `data/`, `docs/`) live inside the document root but are blocked from the web by the root `.htaccess` (plus a deny-all `.htaccess` inside each of `app/`, `config/`, `data/` as a fallback). Verify after deploying — each of these must return **403 Forbidden**, never their contents: - `https://your-domain.com/config/config.php` - `https://your-domain.com/config/credentials.php` - `https://your-domain.com/data/site.json` If they don't, your host ignores `.htaccess` — move `app/`, `config/` and `data/` above the document root and adjust the paths, or contact support. ### Writable directories The PHP process must be able to write to: - `data/` (and `data/galleries/`) — flat-file content - `media/` — hero + showreel images - `config/` — only for the online password change On typical shared hosting (suEXEC/FPM running as your user) this already works; otherwise `chmod 755` the directories (or `775`/`777` as a last resort). ## 5. First-deploy smoke test 1. Open `/admin/`, log in, change the password (Settings). 2. Front page: set your name and intro text, upload a hero image → check the landing page. 3. Showreel: upload 2–3 images → check `/showreel.php`, scroll behavior, and that navigation hides when scrolling down. 4. Galleries: create a test gallery **with password and an expiry date of today**, upload a handful of images. Then: - the upload list shows *done* for each file (webhost → S3 working), - the gallery page asks for the password and then shows images (presigned GETs working), - images load from `your-objectstorage.com`, not from your domain, - tomorrow the gallery shows "not available" (expiry working). 5. Delete the test gallery — the S3 objects are removed as well. ## 5a. Updating an existing installation The application is a plain file tree, so an update is an upload plus one click. 1. **Back up `data/`.** It is the whole database — a few hundred kilobytes. Nothing below deletes anything, but there is no undo either. 2. Upload the new files over the old ones. Do **not** upload `config/`, `data/` or `media/` — those hold your configuration and content, and are never overwritten by an update. New config keys are always optional and read with defaults, so an existing `config/config.php` keeps working unchanged. 3. Open **`/admin/` → Migration** and press *Run migration*. The dashboard shows a banner while anything is outstanding. The step is safe to run more than once and removes nothing: it brings each gallery's data file up to the current format and reports what it did per gallery. Skipping it is not fatal — old files keep being read correctly — but the site is only fully converted once it has run. 4. Reload a gallery page and the backoffice to confirm everything looks right. Existing ZIP archives are **not** invalidated by an update on its own, so no gallery starts a multi-gigabyte rebuild just because you deployed. Only an actual change to a gallery's photos or their arrangement does that. ## 6. Local development ```bash php -S localhost:8080 router.php ``` `router.php` reproduces the `.htaccess` protection for the PHP built-in server (which does not read `.htaccess`); it is only used locally. Everything except real S3 traffic works without credentials; gallery pages render presigned URLs that simply won't resolve until real keys are configured.