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