9.3 KiB
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 — 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 installbuilds it from source you also need a C++ toolchain. - A free host port. The container listens on
3000; the compose file publishes it on8080by 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
git clone https://git.alwisp.com/jason/pnger.git
cd pnger
docker compose up -d --build
Open http://localhost:8080.
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:
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 below.
3. Build and run the image directly
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:
- frontend-builder —
npm installthenvite buildinfrontend/, producingfrontend/dist. - backend-builder —
npm installthentscinbackend/, producingbackend/dist. - runtime —
node:20-alpinewithapk upgrade --no-cacheandsu-exec+shadowinstalled; production-only backend deps (npm install --omit=dev); the compiled backend at/app/dist; the built frontend at/app/dist/public;/app/tempcreated;docker-entrypoint.shinstalled.
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/groupmods 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.<commit-count-1>) 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:
# 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 http://localhost:5173.
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.