Self-hosted. Zero dependencies on the host. No frameworks. No API keys. No CMS subscriptions. A production-grade public library website + staff content management portal serving Fayette County, West Virginia.
- Project Overview
- Key Features
- Architecture Overview
- Technology Stack
- Quick Start
- First-Time Setup
- Usage Flow
- Admin Portal Guide
- API Reference
- Security
- Site Structure
- Daily Staff Usage
- Backups & Recovery
- Updating the Site
- Production Deployment
- Debugging Common Problems
- Project Roadmap
- FAQ
- Maintenance Playbook
- File-Level Specifications
- Operations Runbook
- Commenting Standard
- Project Structure Guide
- Page Maintenance Guide
- Admin API integration tests:
cd admin && npm test
- Docker-based test execution (no host npm required):
docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile test run --rm fcpl-admin-test
- Faster admin frontend/backend iteration without rebuilding image each edit:
docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile dev up -d --build- This mounts
admin/server.jsandadmin/publicdirectly into the running admin container.
The FCPL Website & Staff Portal is a fully self-hosted web platform for Fayette County Public Libraries β a multi-branch public library system serving rural Fayette County, West Virginia. It replaces a legacy website with a modern, accessible, maintainable system that library staff can manage without any technical knowledge.
| Problem | Solution |
|---|---|
| Legacy CMS required vendor maintenance contracts | Fully self-hosted on any Docker-capable server |
| Staff needed technical skills to update content | Browser-based admin portal β no coding needed |
| Website inaccessible to patrons with disabilities | WCAG 2.1 AA compliant throughout |
| No content backup or audit trail | Auto-backup + 60-day recycle bin + activity log |
| Expensive hosted platform subscriptions | Runs on a single VPS or local server, zero SaaS fees |
| Slow page loads from heavy JavaScript frameworks | Zero-framework static HTML β loads in milliseconds |
- Library staff β manage events, hours, announcements, programs, and content through a browser
- Library patrons β find branch hours, upcoming events, digital resources, and library services
- System administrators β deploy and maintain the site via Docker Compose on any Linux server
- Developers β extend the static site or admin API with minimal toolchain overhead
Rural public libraries often lack IT budgets for commercial CMS platforms. This project delivers enterprise-grade security, accessibility, content management, and disaster recovery in a simple Docker stack that any library director can hand off to a successor without technical debt.
| Feature | Description | Impact | Status |
|---|---|---|---|
| Zero-framework static site | Pure HTML/CSS/JS β no React, Vue, or build pipeline | Sub-100ms page loads; no Node.js needed on host | β Live |
| Browser-based CMS | Staff manage all content via a tabbed SPA admin portal | No coding or terminal access required for content changes | β Live |
| WCAG 2.1 AA Accessibility | Skip links, ARIA labels, keyboard navigation, accessibility toolbar | Usable by patrons on screen readers, elderly, and children | β Live |
| Multi-layer security | Nginx + Express dual rate-limiting, bcrypt(12), JWT, Helmet.js | Resistant to brute-force, XSS, clickjacking, CSRF, injection | β Live |
| Soft-delete recycle bin | Deleted items recoverable for 60 days | Prevents accidental permanent loss of content | β Live |
| Auto-backup before writes | Snapshot of data files created before every destructive change | One-click rollback to any previous state | β Live |
| Full audit log | Every staff action logged with timestamp, IP, and detail | Accountability and forensics for every content change | β Live |
| Self-signed / Let's Encrypt TLS | Local: auto-generated self-signed cert. Production: Certbot script | HTTPS on port 8443 out of the box | β Live |
| Image upload pipeline | MIME-type validation, 5 MB cap, random filename on disk | Prevents file-type spoofing and enumerable URLs | β Live |
| Interactive event calendar | Dynamic calendar widget fed by events.json via API |
Patrons see accurate upcoming events in real-time | β Live |
| Bookmobile & Homebound pages | Dedicated service pages for outreach programs | Serves patrons who cannot visit branches in person | β Live |
| 5-branch location maps | OpenStreetMap embed with per-branch hours and directions | Works without Google Maps API; no cost, no rate limits | β Live |
| Digital resources directory | Staff-managed list of eBook/database links via admin portal | Patron-facing resource page always stays current | β Live |
| Holiday closure management | Staff check holiday checkboxes; changes live immediately | Patrons never show up to a closed library | β Live |
| Docker Compose deployment | Two-container stack: fcpl-site (Nginx) + fcpl-admin (Node.js) |
Deploy anywhere Docker runs β VPS, bare metal, local | β Live |
| Simple / No-JS Site | JavaScript-free HTML 4.01 site at /simple/ for IE6β8, Windows XP, and JS-disabled browsers |
Auto-detected via nginx UA map + <noscript> redirect; usable on any browser without JS |
β Live |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Host Machine β
β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Docker Compose Stack β β
β β β β
β β βββββββββββββββββββββββββββββββββββββββββββββββββββββββ β β
β β β fcpl-site (nginx:alpine) β β β
β β β β β β
β β β Port 8080 (HTTP) βββββ Public Browser β β β
β β β Port 8443 (HTTPS) βββββ Public Browser (TLS) β β β
β β β β β β
β β β / β Static HTML (site/) β β β
β β β /pages/* β Static HTML pages β β β
β β β /css, /js β Static assets (30d cache) β β β
β β β /images/* β Uploaded + static images β β β
β β β /data/*.json β Content files (no-cache) β β β
β β β /simple/* β Simple site (IE6β8 / no-JS) β β β
β β β /admin/* ββββββββββββββββββββββββββββββββββββββ β β β
β β β /api/* ββββββββββββββββββββββββββββββββββ β β β β
β β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β β
β β β β β β
β β βββββββββββββββββββββββββββββββββββββββββββββββββΌββββΌβββ β β
β β β fcpl-admin (node:20-alpine) β β β
β β β β β β
β β β Internal port 3000 (not exposed to host) β β β
β β β β β β
β β β GET /admin/ β Staff Portal SPA β β β
β β β POST /admin/api/auth/login β β β
β β β GET|POST|PUT|DELETE /admin/api/events β β β
β β β GET|POST|PUT|DELETE /admin/api/announcements β β β
β β β GET|PUT /admin/api/content/:section β β β
β β β POST /admin/api/upload β β β
β β β GET|DELETE /admin/api/recycle-bin β β β
β β β GET /admin/api/audit-log β β β
β β β GET /admin/api/backups β β β
β β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β β
β β β β
β β Shared Volumes (bind-mounted): β β
β β ./site/data/ ββ /data/ (events.json, content.json) β β
β β ./site/images/ ββ /images/ (uploaded photos) β β
β β ./docker/certs/ β /etc/nginx/certs/ (TLS cert, read-only) β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
| Component | Technology | Role |
|---|---|---|
fcpl-site |
nginx:alpine |
Reverse proxy, static file serving, TLS termination, rate limiting |
fcpl-admin |
node:20-alpine + Express |
REST API for all content mutations; serves staff portal SPA |
site/ |
Plain HTML/CSS/JS | Public-facing website β 16 pages, no JS framework |
admin/public/index.html |
Vanilla JS SPA | Staff content management portal β tabbed, responsive |
site/data/events.json |
JSON | Live event data β read by both nginx (static) and admin API |
site/data/content.json |
JSON | All other site content: branches, hours, programs, announcements |
docker/nginx.conf |
Nginx config | Security headers, CSP, rate limit zones, proxy rules, caching, legacy UA detection β /simple/ |
admin/.env |
Environment file | Credentials and secrets β never committed to git |
Staff makes change in Admin Portal
β
βΌ
POST/PUT/DELETE /admin/api/...
β
βββββββΌββββββ
β Nginx βββββ Rate limit check (zone=admin_write: 30r/m)
βββββββ¬ββββββ
β proxy_pass
βββββββΌβββββββββββββββββββββββ
β Express (fcpl-admin) β
β 1. requireAuth (JWT check) β
β 2. writeLimiter (60/15min) β
β 3. Input sanitisation β
β 4. autoBackup(file) β
β 5. writeJSON (atomic) β
β 6. addToBin / auditLog β
βββββββββββββββββββββββββββββββ
β
βΌ
./site/data/*.json (shared volume)
β
βΌ
Public site reads via fetch() β /data/events.json
Calendar widget renders events in real-time
| Technology | Version | Why Chosen | Alternatives Considered | Tradeoffs |
|---|---|---|---|---|
| Docker + Compose | 29.3 / 5.1 | Zero host dependencies; reproducible deployments; easy backup of bind-mounted volumes | Bare-metal nginx, Podman | Docker requires root or docker group; Compose v2 has no separate install |
| Nginx (Alpine) | latest-alpine | Best-in-class static file serving; sub-ms latency; mature rate-limiting; tiny 8 MB image | Caddy, Apache, Traefik | Caddy has auto-HTTPS but adds complexity; Nginx config is well-understood by admins |
| Node.js (Alpine) | 20 LTS | LTS stability; native crypto module; excellent ecosystem for JWT/bcrypt; 50 MB image |
Deno, Python/Flask, Go | Deno too new for rural IT handoff; Go requires compiled binary; Python slower startup |
| Express.js | 4.18 | Minimal, battle-tested, widely documented | Fastify, Koa, Hono | Fastify is faster but Express has more documentation for non-JS-native maintainers |
| Plain HTML/CSS/JS | ES2020 | Zero build pipeline; no npm vulnerabilities in frontend; loads in <100ms | React, Vue, Astro, HTMX | Frameworks add maintenance burden and version rot β library may not have a dev on staff |
| Package | Version | Purpose | Why This One |
|---|---|---|---|
helmet |
^7.2 | Sets 12 security response headers (CSP, HSTS, X-Frame-Options, etc.) | Industry standard; maintained by the Express team |
bcryptjs |
^2.4 | Password hashing at cost factor 12 | Pure JS (no native bindings); portable across Alpine |
jsonwebtoken |
^9.0 | JWT session tokens (HS256, 8-hour expiry) | Most widely audited JWT library for Node.js |
express-rate-limit |
^7.4 | Request rate limiting per IP (login + write + global) | Works with trust proxy 1 behind Nginx; draft-7 headers |
multer |
^2.0 | Multipart file upload handling | Integrates cleanly with Express; supports fileFilter + limits |
| Library | Purpose | Notes |
|---|---|---|
| Leaflet.js (unpkg) | Interactive branch location maps | No Google Maps API key required; OpenStreetMap tiles are free |
| LibraryThing (ltfl) | Book cover images in catalog widget | Optional external integration |
| CDN Fonts | Typography | fonts.cdnfonts.com β scoped in CSP |
# 1. Navigate to the project
cd /home/kevin/Projects/fayette_lib
# 2. Copy example env file (change password before going live!)
cp admin/.env.example admin/.env
# 3. Generate a self-signed TLS cert for local HTTPS
mkdir -p docker/certs
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout docker/certs/key.pem \
-out docker/certs/cert.pem \
-subj "/CN=localhost"
# 4. Start both containers
sudo docker compose up -d
# 5. Verify both containers are healthy
sudo docker compose ps
# 6. Open the website
xdg-open http://localhost:8080
# 7. Open the staff portal
xdg-open http://localhost:8080/admin/Stop the stack: sudo docker compose down
Rebuild after code changes: sudo docker compose up -d --build
View logs: sudo docker compose logs -f
Docker permission error? Add yourself to the docker group:
sudo usermod -aG docker $USERβ then log out and back in (ornewgrp docker).
| Requirement | Check | Install |
|---|---|---|
| Docker β₯ 24 | docker --version |
docs.docker.com |
| Docker Compose v2 | docker compose version |
Included with Docker Desktop; pacman -S docker-compose on Arch |
| OpenSSL | openssl version |
Pre-installed on most Linux distros |
| curl (optional) | curl --version |
For endpoint verification |
No Node.js, Python, npm, or build tools are needed on the host machine.
# Generate a bcrypt hash (cost factor 12) of your chosen password
sudo docker run --rm node:20-alpine node -e \
"require('bcryptjs').hash('YOUR_PASSWORD', 12).then(h => console.log(h))"The output starts with $2a$12$.... Copy it.
# Generate a strong JWT secret
openssl rand -hex 48Open admin/.env and set both values:
# Paste the bcrypt hash here (preferred β production-safe)
STAFF_PASSWORD_HASH=$2a$12$...your-hash-here...
# Or use plaintext during initial testing only (not for production)
# STAFF_PASSWORD=your-password-here
# JWT signing secret β must be at least 32 characters
JWT_SECRET=your-long-random-secret-here
β οΈ admin/.envis listed in.gitignoreβ it must never be committed to version control.
Local development (self-signed, browser will show a warning):
mkdir -p docker/certs
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout docker/certs/key.pem \
-out docker/certs/cert.pem \
-subj "/CN=localhost"Production (trusted Let's Encrypt cert β after DNS is configured):
bash scripts/get-cert.sh your-library-domain.org admin@yourlibrary.orgsudo docker compose up -d
sudo docker compose ps
# Verify HTTP endpoint
curl -I http://localhost:8080/
# Verify admin portal
curl -I http://localhost:8080/admin/Both should return HTTP/1.1 200 OK (or 302 redirect for HTTPβHTTPS).
Navigate to http://localhost:8080/admin/ and enter your password.
Sessions last 8 hours, then require re-login.
| Setting | Development | Production |
|---|---|---|
| Password | STAFF_PASSWORD=... (plaintext OK) |
STAFF_PASSWORD_HASH=... (bcrypt required) |
| JWT Secret | Any string β₯ 32 chars | openssl rand -hex 48 minimum |
| TLS cert | Self-signed (browser warning) | Let's Encrypt via scripts/get-cert.sh |
| HSTS preload | Off | Enable after HTTPS confirmed stable |
| Port | 8080 / 8443 | 80 / 443 (reverse proxy or firewall redirect) |
Browser visits http://library-domain.org
β
βΌ
[Nginx: fcpl-site]
β
βββ / βββββββββββββββββΊ index.html (homepage)
β β
β JS fetches /data/events.json
β JS fetches /data/content.json
β β
β Renders: announcements sidebar
β upcoming events preview
β homepage image slider
β
βββ /pages/programs.html βΊ Programs & Events page
βββ /pages/locations.html βΊ Branch map + hours (Leaflet)
βββ /pages/ebooks.html βΊ Digital resources list
βββ /pages/bookmobile.htmlβΊ Bookmobile schedule
βββ /pages/homebound.html βΊ Homebound delivery service
βββ /pages/... βΊ 16 total pages
β
βββ Legacy / no-JS browsers
β
βββ IE6β8 or Windows XP: nginx UA map detects user-agent
β βββ 302 redirect βββββββββββββββββββΊ /simple/
β
βββ JavaScript disabled (any modern browser)
βββ index.html: <noscript> meta-refresh β /simple/
βββ pages/*.html: <noscript> amber banner with manual link
Staff opens http://localhost:8080/admin/
β
βΌ
Login screen β POST /admin/api/auth/login
β β
β [rate limited: 5r/m Nginx + 10/15min Express]
β β
β β Returns JWT token (8-hour expiry)
β β Returns 401 (generic message)
β
βΌ
Admin Portal SPA (tabbed)
β
βββ π
Events tab
β βββ GET /admin/api/events β list all
β βββ POST /admin/api/events β add event
β βββ PUT /admin/api/events/:id β edit event
β βββ DELETE /admin/api/events/:id β soft-delete
β
βββ π’ Announcements tab
β βββ CRUD /admin/api/announcements
β
βββ π Branch Hours tab
β βββ PUT /admin/api/content/branches
β
βββ π Programs / π Resources / βοΈ Settings
β βββ GET|PUT /admin/api/content/:section
β
βββ πΌοΈ Images tab
β βββ POST /admin/api/upload (MIME check + 5MB cap)
β
βββ ποΈ Recycle Bin β restore/purge soft-deleted items
βββ π Activity Log β audit trail (last 500 actions)
βββ πΎ Backups β browse/restore auto-snapshots
| Tab | Icon | What You Can Do |
|---|---|---|
| Events | π | Add / edit / delete calendar events; upload event photos |
| Announcements | π’ | Manage homepage sidebar announcements |
| Branch Hours | π | Edit hours for all 5 branches; holiday closures |
| Programs | π | Storytime schedules, adult book club, Library Chef |
| Digital Resources | π | Manage eBook/database/website links |
| Analytics | π | Page-view stats |
| System | β‘ | Server health check; restart signal |
| Settings | βοΈ | Site name, phone number, social media links |
| Calendars | π | Manage shareable calendar feeds |
| Hosting Info | π | DNS, SSL cert status, server IP records |
| Staff | π₯ | Director, assistant, bookmobile staff names |
| Images | πΌοΈ | Homepage slider photos and featured event cards |
| Recycle Bin | ποΈ | Restore items deleted in the last 60 days |
| Activity Log | π | Full audit trail β every change with IP + timestamp |
| Backups | πΎ | Browse and restore automatic pre-change snapshots |
- Click π Events β + Add Event
- Fill in: title, start date/time, end date/time, location, category, description
- Optionally upload a photo (JPEG/PNG/GIF/WebP, max 5 MB)
- Click Save β appears on the public calendar immediately
Recurrence options: None Β· Daily Β· Weekly Β· Monthly (by weekday)
- Click π Branch Hours β Edit Hours next to the branch
- Update rows β +Row to add time ranges, trash icon to remove
- Holiday Closures section at the bottom: check holidays + optional notes
- Click Save Hours β live instantly
- Events, Announcements, Programs, Digital Resources, and Images are soft-deleted
- Items are kept for 60 days, then permanently purged
- Click Restore to put an item back into its original tab
- Click Delete Forever to remove immediately
- Records every save/delete with: timestamp Β· IP address Β· action type Β· detail
- Last 500 entries retained
- Delete actions highlighted in red for quick scanning
- Auto-snapshot of
events.jsonorcontent.jsontaken before every destructive write - Up to 20 backups kept per file (oldest auto-pruned)
- Click Restore to roll back β this also creates a backup of the current state first (undo the undo)
All API endpoints are under /admin/api/. All mutating endpoints require a Bearer JWT token in the Authorization header.
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/admin/api/auth/login |
POST | None | Exchange password for JWT |
Request:
{ "password": "your-staff-password" }Response:
{ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }| Endpoint | Method | Auth | Description |
|---|---|---|---|
/admin/api/events |
GET | β | List all events |
/admin/api/events |
POST | β | Create new event |
/admin/api/events/:id |
PUT | β | Update event by ID |
/admin/api/events/:id |
DELETE | β | Soft-delete event (moves to recycle bin) |
Event object fields:
{
"id": 42,
"title": "Summer Reading Kickoff",
"start": "2026-06-01T10:00:00",
"end": "2026-06-01T12:00:00",
"location": "Oak Hill Branch",
"category": "children",
"description": "Join us for the start of summer reading...",
"recurrence": "none",
"image": "/images/events/1717200000-a1b2c3d4e5f6.jpg"
}| Endpoint | Method | Auth | Description |
|---|---|---|---|
/admin/api/announcements |
GET | β | List all announcements |
/admin/api/announcements |
POST | β | Create announcement |
/admin/api/announcements/:idx |
PUT | β | Update by array index |
/admin/api/announcements/:idx |
DELETE | β | Soft-delete announcement |
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/admin/api/content/:section |
GET | β | Read a content section |
/admin/api/content/:section |
PUT | β | Update a content section |
Allowed sections: site Β· staff Β· branches Β· programs Β· digital_resources Β· services Β· memorial_program Β· hosting Β· holiday_closures Β· homepage_features Β· jobs
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/admin/api/upload |
POST | β | Upload image (multipart/form-data) |
Accepted: JPEG Β· PNG Β· GIF Β· WebP Β· Max 5 MB
Returns: { "url": "/images/events/timestamp-randomhex.ext" }
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/admin/api/recycle-bin |
GET | β | List deleted items |
/admin/api/recycle-bin/:bin_id/restore |
POST | β | Restore an item |
/admin/api/recycle-bin/:bin_id |
DELETE | β | Permanently delete |
/admin/api/audit-log |
GET | β | Last 500 audit entries |
/admin/api/backups |
GET | β | List available backups |
/admin/api/backups/restore |
POST | β | Restore a backup |
| Status | Meaning |
|---|---|
200 OK |
Read success |
201 Created |
Write success |
400 Bad Request |
Invalid input |
401 Unauthorized |
Missing or expired JWT |
404 Not Found |
Resource doesn't exist |
413 Payload Too Large |
Image over 5 MB |
429 Too Many Requests |
Rate limit exceeded |
500 Internal Server Error |
Server-side failure |
Internet Request
β
βΌ
[Nginx β Layer 1]
β’ Rate limit zones (general: 120r/m, admin_write: 30r/m, login: 5r/m)
β’ Returns 429 on breach (not 503)
β’ Security headers on every response
β’ TLS 1.2/1.3 with strong cipher suite
β
βΌ
[Express β Layer 2]
β’ apiLimiter: 200 req/15min per IP
β’ writeLimiter: 60 writes/15min per IP
β’ loginLimiter: 10 attempts/15min per IP
β’ JWT verification (HS256, 8-hour expiry)
β’ Helmet.js (12 security headers)
β
βΌ
[Application β Layer 3]
β’ Password: bcrypt(12) + constant-time compare
β’ Input: all fields sanitized and length-capped
β’ Uploads: MIME-type checked, random filename
β’ Files: path traversal prevention on all reads/writes
β’ Writes: atomic (temp-file rename; no corruption on crash)
| Header | Value |
|---|---|
X-Frame-Options |
SAMEORIGIN |
X-Content-Type-Options |
nosniff |
Referrer-Policy |
strict-origin-when-cross-origin |
Permissions-Policy |
Camera/mic/payment blocked; geolocation self-only |
X-DNS-Prefetch-Control |
off |
Cross-Origin-Opener-Policy |
same-origin |
Content-Security-Policy |
Restricts scripts/styles/images/frames to trusted origins |
| Header | Value |
|---|---|
Strict-Transport-Security |
max-age=31536000; includeSubDomains |
X-Frame-Options |
DENY (stricter for admin) |
Content-Security-Policy |
Admin-specific; blocks all external CDNs |
| Endpoint | Nginx | Express |
|---|---|---|
Login (/admin/api/auth/login) |
5 req/min, burst 3 | 10 req/15 min |
| Admin API writes | 30 req/min, burst 10 | 60 req/15 min |
| All admin API | β | 200 req/15 min |
| Public site | 120 req/min, burst 30 | β |
| OWASP Risk | Mitigation |
|---|---|
| A02 Cryptographic Failures | bcrypt(12) for passwords; HS256 JWT with strong secret |
| A03 Injection | All inputs sanitized and truncated; no eval/exec; no SQL |
| A05 Misconfiguration | Helmet defaults; server_tokens off; no X-Powered-By header |
| A06 Vulnerable Components | Pinned npm dependencies; minimal image size |
| A07 Auth Failures | Rate limiting on login; JWT expiry; constant-time password comparison |
| A08 Software Integrity | Atomic writes (writeβtempβrename); no partial JSON on crash |
# Step 1: Generate a new bcrypt hash
sudo docker run --rm node:20-alpine node -e \
"require('bcryptjs').hash('NEW_PASSWORD_HERE', 12).then(h => console.log(h))"
# Step 2: Edit admin/.env β replace STAFF_PASSWORD_HASH
nano /home/kevin/Projects/fayette_lib/admin/.env
# Step 3: Restart admin container (< 5 seconds)
sudo docker compose restart fcpl-adminTo immediately invalidate all active sessions, also rotate JWT_SECRET in .env and restart.
- Single shared password β all staff use one credential. Departing staff access is revoked by changing the password. The Activity Log provides per-IP accountability.
- No TOTP/2FA β suitable for internal deployment; consider adding for internet-facing portals.
- Self-signed cert β local only; use
scripts/get-cert.shfor production.
fayette_lib/
βββ README.md β This file
βββ docker-compose.yml β Starts both containers
βββ .gitignore
β
βββ admin/ β Staff portal backend
β βββ .env.example β Template β copy to .env and edit
β βββ .env β β οΈ SECRETS β never commit
β βββ server.js β Express REST API (~700 lines)
β βββ package.json β 5 production dependencies
β βββ Dockerfile β node:20-alpine, 9 steps
β βββ public/
β βββ index.html β Staff portal SPA (vanilla JS)
β
βββ docker/
β βββ Dockerfile β nginx:alpine, 8 steps
β βββ nginx.conf β Nginx config + security headers
β βββ docker-entrypoint.sh
β βββ certs/
β βββ cert.pem β TLS certificate (self-signed or LE)
β βββ key.pem β TLS private key
β
βββ site/ β Public website (static files)
β βββ index.html β Homepage
β βββ 404.html
β βββ robots.txt
β βββ favicon.ico
β βββ css/
β β βββ style.css β All styles; edit :root vars to retheme
β βββ js/
β β βββ main.js β Content loading, nav, tabs
β β βββ calendar.js β Public calendar widget
β β βββ a11y.js β Accessibility toolbar
β βββ pages/ β All secondary pages
β β βββ about.html
β β βββ programs.html
β β βββ programs-adults.html
β β βββ programs-children.html
β β βββ programs-teens.html
β β βββ programs-community.html
β β βββ locations.html β Branch map (OpenStreetMap + Leaflet)
β β βββ research.html
β β βββ ebooks.html
β β βββ bookmobile.html
β β βββ homebound.html
β β βββ archives.html
β β βββ news.html
β β βββ jobs.html
β β βββ catalog.html
β β βββ myaccount.html
β βββ simple/ β No-JS / legacy browser version (IE6+)
β β βββ index.html β Simple homepage β HTML 4.01, no JS required
β β βββ about.html
β β βββ locations.html
β β βββ programs.html
β β βββ ebooks.html
β β βββ bookmobile.html
β β βββ homebound.html
β β βββ research.html
β β βββ news.html
β β βββ jobs.html
β β βββ catalog.html
β β βββ archives.html
β β βββ css/
β β βββ simple.css β IE6+ compatible stylesheet (float layout, no CSS custom properties)
β βββ images/ β Static images + uploaded event photos
β βββ data/
β βββ events.json β All calendar events (live data)
β βββ content.json β Branches, hours, programs, announcements
β βββ audit_log.json β Staff action log (0o640 β not public)
β βββ recycle_bin.json β Soft-deleted items (0o640)
β βββ backups/ β Auto-snapshots (up to 20 per file)
β
βββ scripts/
β βββ get-cert.sh β Let's Encrypt cert for production
βββ docs/
βββ site-audit.md β Original site content audit
The two key data files:
| File | Contents | Who edits it |
|---|---|---|
site/data/events.json |
All calendar events with dates, times, locations | Admin portal β Events tab |
site/data/content.json |
Branch hours, announcements, programs, staff, settings | Admin portal β all other tabs |
All content management happens at http://localhost:8080/admin/ β no terminal access needed.
- π Events β + Add Event
- Fill in title, date/time, location, category, description
- Optionally upload a photo
- Save β event appears on the public calendar immediately
- π’ Announcements β + Add Announcement
- Fill in title, body text, optional link
- Save β appears in the homepage sidebar immediately
- π Branch Hours β Edit Hours next to the branch
- Update time rows; +Row adds a new row; trash icon removes
- Save β live immediately
- π Branch Hours β scroll to Holiday Closures
- Check the holiday checkboxes when the library is closed
- Add any patron-facing notice in the text field
- Save Holiday Closures
- π Digital Resources β Add / Edit / Delete eBook and database links
- πΌοΈ Images β Upload slider photos and featured event cards
- Keep original images under 1 MB for fast page loads
A snapshot of events.json or content.json is saved to site/data/backups/ before every destructive write through the admin portal. Up to 20 backups are kept per file (oldest auto-pruned).
# Full data export to a timestamped folder
cp -r /home/kevin/Projects/fayette_lib/site/data/ ~/fcpl-backup-$(date +%Y%m%d)/- Log into the admin portal
- Click πΎ Backups
- Find the backup and click Restore
# List available backups
ls /home/kevin/Projects/fayette_lib/site/data/backups/
# Restore events
cp site/data/backups/events_2026-03-31T14-22-00.json site/data/events.json
# Restore content
cp site/data/backups/content_2026-03-31T09-15-00.json site/data/content.jsonNo container restart needed β changes are live immediately since files are bind-mounted.
sudo docker compose up -d --buildNo rebuild needed β volume-mounted files are live immediately.
sudo docker compose up -d --buildOpen site/css/style.css and edit the CSS variables in :root { ... } at the top of the file. Then rebuild.
- Look up the address at nominatim.openstreetmap.org
- Find the
BRANCHESobject insite/pages/locations.html - Update
latandlngfor the branch - Rebuild
| Resource | Minimum | Recommended |
|---|---|---|
| CPU | 1 vCPU | 2 vCPU |
| RAM | 512 MB | 1 GB |
| Disk | 5 GB | 20 GB |
| OS | Any Linux with Docker | Ubuntu 24 LTS / Arch |
| Inbound ports | 80, 443 | 80, 443 |
Point your domain's A record to the server's public IP, then:
# After DNS propagates, obtain a real TLS certificate
bash scripts/get-cert.sh your-library-domain.org admin@yourlibrary.org[ ] Set STAFF_PASSWORD_HASH (bcrypt, not plaintext) in admin/.env
[ ] Set JWT_SECRET to output of: openssl rand -hex 48
[ ] Replace self-signed cert with Let's Encrypt cert
[ ] Confirm HTTPS works at https://your-domain.org
[ ] Set HSTS preload: true in admin/server.js (after HTTPS is stable)
[ ] Add the user to the docker group: sudo usermod -aG docker $USER
[ ] Set up daily data backup cron: cp -r site/data/ ~/backups/fcpl-$(date +%Y%m%d)/
[ ] Configure server firewall to allow only ports 80 and 443
[ ] Point domain DNS A record to server IP
# Check container status
sudo docker compose ps
# View logs
sudo docker compose logs fcpl-site
sudo docker compose logs fcpl-admin
# Rebuild from scratch
sudo docker compose down
sudo docker compose up -d --build- Verify
admin/.envexists and containsSTAFF_PASSWORD_HASHorSTAFF_PASSWORD - Hash must start with
$2a$12$... JWT_SECRETmust be at least 32 characters- Restart:
sudo docker compose restart fcpl-admin
# Validate JSON syntax
python3 -m json.tool site/data/events.jsonEvents need: "id", "title", and "start" (ISO 8601: "2026-06-01T10:00:00").
- Max size: 5 MB
- Accepted types: JPEG, PNG, GIF, WebP
- Check disk space:
df -h - Check logs:
sudo docker compose logs fcpl-admin
- You've hit the write rate limit (60 ops/15 minutes per IP)
- Wait 15 minutes β this is intentional security behaviour
Expected for local development. For production, run scripts/get-cert.sh to get a trusted Let's Encrypt cert.
The docker/certs/ directory is missing cert.pem / key.pem. Generate them:
mkdir -p docker/certs
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout docker/certs/key.pem -out docker/certs/cert.pem -subj "/CN=localhost"cat site/data/audit_log.json | python3 -m json.tool | head -100| Phase | Timeline | Goals | Status |
|---|---|---|---|
| Phase 1 β Core Site | Q1 2026 | Static website with all 16 pages; Docker stack; WCAG 2.1 AA | β Complete |
| Phase 2 β Admin Portal | Q1 2026 | Staff CMS: events, hours, announcements, programs, images | β Complete |
| Phase 3 β Security Hardening | Q1 2026 | OWASP Top 10 mitigations; dual-layer rate limiting; bcrypt + JWT | β Complete |
| Phase 4 β Resilience | Q1 2026 | Auto-backup, recycle bin, activity log, atomic writes | β Complete |
| Phase 5 β Production TLS | Q2 2026 | Let's Encrypt cert automation; HSTS preload | β Complete |
| Phase 5b β Legacy Browser Support | Q2 2026 | No-JS simple site at /simple/; nginx IE6β8 + Windows XP UA detection; <noscript> redirects and banners on all pages |
β Complete |
| Phase 6 β Production Deploy | Q2 2026 | Go live at fayette.lib.wv.us; DNS cutover; smoke tests | π‘ In Progress |
| Phase 7 β Analytics | Q3 2026 | Self-hosted page-view analytics (no Google Analytics) | β Planned |
| Phase 8 β Mobile App | Q4 2026 | Progressive web app (PWA) manifest + offline support | β Planned |
| Phase 9 β Multi-Staff | 2027 | Optional: per-staff accounts with role-based permissions | β Backlog |
Q: How do I add a new branch? Go to admin β π Branch Hours β scroll to the bottom β + Add Branch.
Q: Can I restore something I deleted by mistake? Yes β ποΈ Recycle Bin tab. Items are kept for 60 days.
Q: How do I close the library for a holiday?
Check the holiday in π Branch Hours β Holiday Closures, or add a calendar event with category closure.
Q: What is the simple site and who uses it?
The simple site at /simple/ is a JavaScript-free HTML 4.01 version of the site. It is served automatically to browsers that don't support modern JS β specifically IE6, IE7, IE8, and any browser running on Windows XP. It is also shown to any browser with JavaScript manually disabled, via a <noscript> meta-refresh on the homepage and amber warning banners on all subpages.
Q: How do I update the content in the simple site?
Edit the HTML files directly in site/simple/. The content is hardcoded (no JSON fetching). After editing, rebuild with sudo docker compose up -d --build.
Q: A staff member left β how do I prevent access? Change the admin password. See Section 10 β Security. Takes effect immediately on restart.
Q: How do I see what someone changed? π Activity Log tab β every action is recorded with IP and timestamp.
Q: The site looks wrong after I edited something β how do I undo?
πΎ Backups tab β restore the snapshot before your change. Or from terminal: copy from site/data/backups/.
Q: How do I move this to a real web server?
- Copy the project directory to the server
- Install Docker
sudo docker compose up -d- Point your domain DNS to the server IP
- Run
scripts/get-cert.sh your-domain.org your@email.com - Done β no other dependencies needed
Q: How do I back up everything?
cp -r site/data/ ~/fcpl-backup-$(date +%Y%m%d)/Q: Can staff access the portal from another computer?
Yes β navigate to http://SERVER_IP:8080/admin/ from any machine on the same network.
Q: Why aren't we using WordPress / Drupal / Squarespace? Those platforms require ongoing maintenance, license fees, plugin management, and vulnerability patching. This codebase has 5 npm dependencies, no database, and runs on any $6/month VPS. The staff can manage all content without ever touching code.
Built for Fayette County Public Libraries, Fayette County, West Virginia. All site content and data belong to FCPL.