# 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 ``` 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. ## 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.