# 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/`,
`manage-client/`, `migrations/`, `scripts/`) 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/` and `manage-client/` as a fallback).
The manage client is only ever reached through PHP includes — its update and
backup functions are exposed at `/admin/maintenance.php`, behind the login.
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`
- `https://your-domain.com/manage-client/config.php`
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
- `data/manage/` — backups, update working files (created automatically)
- the whole document root, if updates are to be deployed through the manage
client rather than by FTP
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. Backups and updates (manage client)
`manage-client/` connects the installation to a
[manage server](https://manage.med0.de) that keeps the backups and hands out
releases. Once it is configured, a backup is a button and so is an update.
Everything it does is also available from the shell, and both paths call the
same code.
Skipping this is a supported choice: without `manage-client/config.php` the
site runs exactly as before, and the manual route in **5b** stays open.
### One-time setup
1. On the manage server, create an instance and copy the token — it is shown
**once**.
2. On the webhost:
```bash
cp manage-client/config.sample.php manage-client/config.php
```
Fill in `MANAGE_INSTANCE` and `MANAGE_TOKEN`. `MANAGE_SERVER_URL` is already
set. Nothing else needs changing: backup sources, protected paths, the
version file and the post-update hook are configured for this project.
3. Check it:
```bash
php manage-client/bin/manage-client.php status
```
Or open **`/admin/` → Maintenance**, which shows the same thing.
4. Make the first backup (the button, or `manage-client.php backup`).
`manage-client/config.php` holds the token. It is gitignored, excluded from
release packages, and never inside a backup.
### What a backup contains
`data/` (galleries, showreel, front page, settings) and `media/` (hero and
showreel images) — the operational data, roughly a few hundred kilobytes plus
whatever the local images weigh.
Two deliberate omissions:
- **Gallery photos.** They live in the S3 bucket, which is their own backup;
copying gigabytes into a ZIP nightly would achieve nothing.
- **`config/config.php` and `config/credentials.php`.** A backup is uploaded to
the manage server and can be downloaded there, so it must not carry the S3
keys or the admin password hash. Keep one copy of those two files somewhere
safe by hand — they are short and they change almost never.
There is **no restore command**. A backup is a ZIP: unpack it over `data/` and
`media/`, and the installation is back.
### The schedule, without cron
Shared hosting rarely has dependable cron, so the backoffice drives the two
recurring jobs itself — the same mechanism gallery archives already use:
| Job | When | Trigger |
| --- | --- | --- |
| Backup | the newest scheduled backup is older than a week | any admin page load past that point |
| Heartbeat | hourly | the same |
An admin page load past the interval hands the work to `manage-worker.php` in
the background and returns immediately; the operator never waits on a backup.
If the host blocks outbound HTTP to itself — which also breaks archive
building — the job runs inline instead, after the page has been sent. The
current state is on **Admin → Maintenance → Schedule**.
Two things are deliberately *not* on that schedule:
- **Updates.** Never automatic. See below.
- **The release check.** It is a request to the manage server, so it happens
when the Maintenance page is opened and nowhere else. No page of the
backoffice waits on the network to render.
Tuning: `MANAGE_BACKUP_AUTO_INTERVAL_SECONDS` in `manage-client/config.php`
(a week; `0` turns the backup schedule off) and `manage.heartbeat_interval` in
`config/config.php` (an hour).
If the host *does* offer cron, use it — `scripts/manage-client.cron` has lines
for both variants: a `curl` at `manage-worker.php?key=…` every 15 minutes (the
key is in `data/worker-key.json`), or the CLI on an explicit schedule. Both run
the same code as the web fallback, which then finds nothing due.
### Running an update
**`/admin/` → Maintenance → Deploy update**, with *Create a backup first* left
ticked. From the shell it is `manage-client.php check` and then
`manage-client.php update`.
What that does, in order: downloads the release, verifies size and SHA-256,
copies every file over the installation (saving each replaced file to
`data/manage/updates/`), runs any migrations from `migrations/`, then runs
`app/after-update.php`.
What it deliberately does not do:
- **No rollback.** The replaced files are kept for manual recovery, and that
is the whole safety net. This is why the backup checkbox is ticked.
- **No maintenance mode.** The site stays online while files are replaced.
Update during a quiet moment.
- **No deletions.** A file dropped from a release stays behind on the
installation; removing it is a job for a migration.
- **Never touches** `config/`, `data/`, `media/` or `.git/`.
Afterwards the dashboard tells you whether the per-gallery data migration in
**Admin → Data migration** is outstanding (see 5b, step 3) — that one is
separate, and stays your decision.
If a migration fails, the update reports it loudly and the files are still
deployed: fix the cause, then press *Run migrations* on the Maintenance page.
### Versions and building a release
The installed version lives in `app/version.php` as `APP_VERSION`, and nothing
writes it at runtime: it changes when a release package copies that file over.
It is shown at the foot of every backoffice page, on the dashboard, and on the
Maintenance page, and it is what the manage server compares against to decide
whether an update is available.
The format is semantic versioning with a `v` prefix — `vMAJOR.MINOR.PATCH`,
currently **v1.2.0**. Both the client and the manage server reject anything
else, so there are no `-beta` or `-rc` suffixes: patch for fixes, minor for
features, major for a release that needs manual steps.
For whoever maintains the software rather than the site:
```bash
./scripts/create-release-zip.sh v1.3.0
```
It writes the version into `app/version.php`, packs every git-tracked file
except configuration and content, and prints the SHA-256. Upload the resulting
`build/releases/foto-portfolio-v1.3.0.zip` under **Releases** on the manage
server. Full reference: [the client documentation](https://manage.med0.de/client-docs/),
06_UPDATE_PACKAGING — and `migrations/README.md` for changes that need a
migration.
## 5b. Updating by hand
Without the manage client — or when you would rather use FTP — the application
is a plain file tree, so an update is an upload plus one click.
1. **Back up `data/` and `media/`.** They are the whole database — a few
hundred kilobytes plus the local images. 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.
Leave `manage-client/config.php` in place too.
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.