diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..d177fbf --- /dev/null +++ b/docs/INSTALL.md @@ -0,0 +1,164 @@ +# Installation & configuration + +PNGer is a single stateless container: Express serves both the API and the compiled Svelte frontend on one port. There is no database, no persistent volume, and nothing to migrate. + +| Path | Use when | Where | +| --- | --- | --- | +| **Unraid Docker GUI** | Production on Unraid. | [UNRAID.md](UNRAID.md) — click-by-click. | +| **`docker compose`** | Any Docker host. | §2 below. | +| **Plain `docker run`** | Ad hoc. | §3 below. | +| **Local Node** | Development. | §4 below. | + +--- + +## 1. Prerequisites + +- **Docker** for the container paths. Nothing else — no runtime deps on the host. +- **Node 20+ and npm** for local development. Sharp ships prebuilt binaries for common platforms; if `npm install` builds it from source you also need a C++ toolchain. +- **A free host port.** The container listens on `3000`; the compose file publishes it on `8080` by default. + +No volume is required. No reverse proxy is required either, though you will want one if you expose it beyond the LAN — PNGer has **no authentication of any kind**. + +--- + +## 2. `docker compose` + +```bash +git clone https://git.alwisp.com/jason/pnger.git +cd pnger +docker compose up -d --build +``` + +Open . + +`docker-compose.yml` builds locally, tags `pnger:latest`, publishes `${HOST_PORT:-8080}:3000`, sets `restart: unless-stopped`, caps the container at `512m` / `1.0` CPU (`MEM_LIMIT` / `CPU_LIMIT`), rotates JSON logs at 10 MB × 3, and runs the same healthcheck as the image. Override anything through a `.env` file next to the compose file or through the shell environment: + +```bash +HOST_PORT=9000 TZ=America/Chicago docker compose up -d +``` + +The optional `/app/temp` volume mount is commented out in the compose file and is not needed — see [temp directory](#the-temp-directory-does-nothing) below. + +--- + +## 3. Build and run the image directly + +```bash +docker build -t pnger:latest . +docker run -d --name pnger -p 8080:3000 \ + -e PUID=99 -e PGID=100 -e TZ=America/Chicago \ + --restart unless-stopped pnger:latest +``` + +The `Dockerfile` is a three-stage build: + +1. **frontend-builder** — `npm install` then `vite build` in `frontend/`, producing `frontend/dist`. +2. **backend-builder** — `npm install` then `tsc` in `backend/`, producing `backend/dist`. +3. **runtime** — `node:20-alpine` with `apk upgrade --no-cache` and `su-exec` + `shadow` installed; production-only backend deps (`npm install --omit=dev`); the compiled backend at `/app/dist`; the built frontend at `/app/dist/public`; `/app/temp` created; `docker-entrypoint.sh` installed. + +`npm install` is used rather than `npm ci` on purpose — the committed lockfiles are stubs, and caret ranges in `package.json` are what actually pin the tree. + +At start, `docker-entrypoint.sh` runs as root: it `usermod`/`groupmod`s the built-in `node` user to the requested `PUID`/`PGID`, `chown -R node:node /app`, then `exec su-exec node "$@"`. The app itself never runs as root. + +### CI + +`.gitea/workflows/docker-build.yml` runs on every push to `main` (plus `workflow_dispatch`) on the `host`-labelled Gitea runner — bundled Docker CLI, mounted `/var/run/docker.sock`, legacy builder. It logs in to `registry.alwisp.com` with `REGISTRY_USER` / `REGISTRY_TOKEN`, builds with the labels `org.alwisp.git-sha`, `org.alwisp.version` (`v1.`) and `org.alwisp.repo`, pushes `registry.alwisp.com/jason/pnger:latest`, then prunes dangling images on the host. + +Only `:latest` is published — there is no immutable per-SHA tag, so rollback means rebuilding from an older commit. + +To run the published image instead of building locally, point `docker run` (or the Unraid **Repository** field) at `registry.alwisp.com/jason/pnger:latest` after a `docker login registry.alwisp.com`. + +--- + +## 4. Local development + +Two terminals: + +```bash +# terminal 1 — API on :3000, ts-node-dev with --respawn +cd backend +npm install +npm run dev + +# terminal 2 — Vite dev server on :5173 with HMR +cd frontend +npm install +npm run dev +``` + +Open . + +`frontend/src/lib/api.ts` switches on `import.meta.env.DEV`: in dev it posts to `http://localhost:3000/api`, in a production build to the same-origin `/api`. There is no Vite proxy — the backend's `app.use(cors())` allows the cross-origin call. + +| Script | Where | Does | +| --- | --- | --- | +| `npm run dev` | backend | `ts-node-dev --respawn --transpile-only src/index.ts` | +| `npm run build` | backend | `tsc` → `dist/` | +| `npm start` | backend | `node dist/index.js` | +| `npm run dev` | frontend | Vite dev server on 5173 | +| `npm run build` | frontend | `vite build` → `frontend/dist` | +| `npm run preview` | frontend | Serve the production build locally | + +There are no tests and no lint step. Verification is manual: resize a large PNG, convert it to WebP and JPEG, try each crop anchor, toggle the theme, paste from the clipboard. + +--- + +## Environment variables + +Everything is optional — the image ships a working default for each. There is no `.env` file in the container; set them on the container. + +| Variable | Default (image) | Read by | Notes | +| --- | --- | --- | --- | +| `PORT` | `3000` | `backend/src/index.ts` | Express listen port. Changing it means changing the healthcheck and port mapping too — easier to leave it and remap on the host. | +| `PUID` | `99` | `docker-entrypoint.sh` | UID the `node` user is remapped to. `99` = Unraid `nobody`. | +| `PGID` | `100` | `docker-entrypoint.sh` | GID the `node` group is remapped to. `100` = Unraid `users`. | +| `TZ` | `UTC` | base image | Container timezone, e.g. `America/Chicago`. Affects log timestamps only. | +| `NODE_ENV` | `production` | Express / Node | Set to `development` locally. | +| `MAX_FILE_SIZE` | `10485760` | **nothing** | ⚠️ Declared in the `Dockerfile`, the compose file and the Unraid docs, but the live upload middleware (`backend/src/routes/image.ts`) creates `multer` with no `limits`, so no size cap is applied. The only code that ever honoured it is the dead `backend/src/server.js`. Treat uploads as unbounded until this is wired up. | +| `TEMP_DIR` | `/app/temp` | **nothing** | ⚠️ See below. | +| `CORS_ORIGIN` | — | **nothing** | ⚠️ Listed in `backend/.env.example` and older docs. `index.ts` calls `app.use(cors())` with no options, so all origins are allowed and this variable is ignored. | +| `HOST_PORT` | `8080` | `docker-compose.yml` | Host side of the port mapping. Compose only. | +| `MEM_LIMIT` | `512m` | `docker-compose.yml` | Container memory cap. Compose only. | +| `CPU_LIMIT` | `1.0` | `docker-compose.yml` | Container CPU cap. Compose only. | +| `VITE_API_URL` | — | **nothing** | ⚠️ Documented historically; `lib/api.ts` derives the base URL from `import.meta.env.DEV` instead. Setting it has no effect. | + +`backend/.env.example` also predates the current server: it lists `MAX_FILE_SIZE=10` (megabytes) while the `Dockerfile` sets `10485760` (bytes). Neither is read today. + +### The temp directory does nothing + +`/app/temp` is created by the `Dockerfile`, `TEMP_DIR` points at it, and `UNRAID.md` offers an optional volume mapping for it. Nothing writes there. Uploads live in memory (`multer.memoryStorage()`), Sharp transforms the buffer, Express returns it. Mounting a volume is harmless but pointless. + +--- + +## Health checks + +The image's `HEALTHCHECK` requests `http://localhost:3000/` every 30 s and passes on HTTP 200 — that is the SPA `index.html`, so it confirms the process is up and serving. + +Be careful with `/api/health`: **it does not exist.** The router only defines `POST /api/transform`, so a GET to `/api/health` falls through to the `app.get("*")` SPA handler and returns `index.html` with a 200. Any monitor pointed at it will report healthy no matter what state the API is in. Use `POST /api/transform` with a small test image if you need a real probe. + +--- + +## Upgrading + +- **Compose / local build:** `git pull && docker compose up -d --build`. +- **Registry image:** `docker pull registry.alwisp.com/jason/pnger:latest && docker restart pnger`, or Unraid → **Docker** tab → **Force update**. + +There is no state, so upgrades and rollbacks are just container recreations. Nothing to back up — but note that only `:latest` is published, so rolling back means rebuilding from the older commit. + +--- + +## Troubleshooting + +| Symptom | Cause / fix | +| --- | --- | +| Container exits immediately | Host port already in use, or `PUID`/`PGID` collide with an existing user in a way `usermod` rejects. `docker logs pnger`. | +| `Transform failed` in the UI | The API returned non-200. Check `docker logs` — Sharp throws a 500 on files it cannot decode (the client only checks the MIME type starts with `image/`). | +| Large upload hangs or the container OOMs | No upload size limit is enforced and processing is entirely in memory. Raise `MEM_LIMIT`, or put a body-size cap on your reverse proxy. | +| Preview does not match the download | Expected. The preview is a Canvas re-encode, the output is Sharp. PNG quality is ignored by Canvas, and the preview will upscale where the server's `withoutEnlargement: true` will not. | +| Preview never appears | Browser console — the preview path is pure Canvas API and fails silently into `console.error`. | +| Port already in use in dev | `lsof -ti:3000 \| xargs kill -9` | +| Sharp fails to load after a Node upgrade | `cd backend && npm rebuild sharp` | +| Docker build fails oddly | `docker builder prune`, then rebuild. | + +--- +*MPM — Born to Innovate. Built to Last.*