# Portfolio Builder One Next.js application, one SQLite file, and local uploads. Each installation hosts one portfolio with one owner. Saving in `/admin` updates the next public request without rebuilding. The existing design, animations, and section order are retained. ## Run locally Use Node.js 22 or newer. From the repository root: ```sh npm ci cp .env.example .env npm run maintenance -- init npm run maintenance -- create-owner npm run dev ``` Open `http://localhost:3000/admin`. `create-owner` prompts for an email and a hidden password (12–128 characters). It refuses to create a second account. `reset-password` changes the owner's password and revokes all existing sessions. There is no public registration or email delivery requirement. ```sh npm run maintenance -- reset-password ``` For automated provisioning only, pass `OWNER_EMAIL` and `OWNER_PASSWORD` through the process environment. Do not bake them into an image, commit them, or leave them in a production container's environment after provisioning. ## Docker on your own server The generic Compose file does not depend on the original private Caddy network. Set `SITE_URL` in `.env` to the exact public origin, including the scheme and any nonstandard port. Set `PORTFOLIO_DATA_DIR` to an absolute, local disk directory. The container runs as UID/GID 1001; this directory must be writable by that user. ```sh mkdir -p /srv/portfolio/data sudo chown 1001:1001 /srv/portfolio/data # .env: SITE_URL=https://portfolio.example.com # .env: PORTFOLIO_DATA_DIR=/srv/portfolio/data docker compose -f compose.selfhost.yaml up -d --build docker compose -f compose.selfhost.yaml exec portfolio-app node maintenance.mjs create-owner ``` Compose binds port 3000 to the host's loopback address. Point your existing HTTPS reverse proxy to `127.0.0.1:3000`. For a local trial, keep `SITE_URL=http://localhost:3000`. Set `PORT` to change the published host port and adjust `SITE_URL` accordingly. The proxy must replace incoming forwarded IP headers with the real client address so authentication rate limiting is reliable. The production image includes `maintenance.mjs`; it does not need TypeScript, Drizzle Kit, npm installs, or a compiler at runtime. Startup applies the committed SQL migrations and seeds samples only when the database is empty. `/api/health` checks database readiness without rendering the portfolio. Sharp uses native image processing where supported and includes a WebAssembly fallback for servers without the required x86-64 CPU features. The fallback preserves uploads and image optimization, but can use more CPU time. Keep `@img/sharp-wasm32` at the same version as `sharp` when upgrading. Use one application instance with a local filesystem. Do not mount the SQLite database over NFS/SMB, use an ephemeral serverless disk, or run multiple replicas against the same data volume. PostgreSQL would be the next step if those become requirements. ## Persistent files and configuration | Setting/file | Purpose | | --- | --- | | `SITE_URL` | Exact origin used for authentication and cross-site request checks | | `DATA_DIR` | Native runtime directory, default `./data`; Docker uses `/data` | | `PORTFOLIO_DATA_DIR` | Host bind mount for the generic Compose file | | `BETTER_AUTH_SECRET` | Optional secret of at least 32 characters | | `portfolio.sqlite` | Content, owner account, and sessions | | `uploads/` | Validated images and PDF attachments | | `auth-secret` | Generated secret when no environment secret was supplied | Keep the generated `auth-secret` with the database. When using an environment secret, preserve it separately through your host's secret-management process. The Next.js image cache is separate and may be discarded safely. ## Editing and translations - **Site settings:** avatar, contact destinations, social accounts, default language, and search metadata. Empty optional URLs hide the corresponding link. - **Profile & sections:** hero/about/footer text, section headings, journey steps, and visibility. Section order is fixed by the template. - **Content lists:** projects and case studies, jobs, qualifications, milestones, and skill groups. Lower position numbers appear first; nested lists have up/down controls. Entry IDs are stable URL identifiers and cannot be renamed through the editor after creation. - **Languages:** add a lowercase language tag such as `de`, `ar`, or `pt-br`, a native display name, and text direction. English and Persian labels are built in. Other labels fall back to English until translated. Named placeholders such as `{title}` and `{current}` must be retained. - **Media library:** upload JPEG, PNG, WebP, or PDF files, up to 10 MB each. Images are decoded and converted to WebP; metadata is removed. Image pickers use the returned dimensions automatically. PDFs are served as downloads. Referenced uploads cannot be deleted, including references in hidden content and untranslated records. The default language uses `/` and `/projects/example`. Other enabled languages use paths such as `/fa` and `/fa/projects/example`. Missing portfolio translations fall back to the default-language content; the editor identifies missing translations. Translate each section and add metadata for a language before making it the default. Changing the default changes which language the root URLs serve. Disabled languages return 404; their saved translations remain. Fonts use the template's English/Persian fonts and system fallbacks for other scripts. The admin interface itself is English. Changes appear on a fresh page request. An already-open visitor page does not receive live push updates. Hidden projects are excluded from navigation and return 404 when accessed directly. Upload URLs are public and immutable, so upload a replacement rather than overwrite an existing URL. Deleting content does not recall files already cached by a visitor. ## Import this repository's original portfolio New installations show generic samples. To preserve this repository's original English/Persian content and project URLs, run the explicit import **before editing any content**: ```sh npm run maintenance -- import-legacy # Or in Docker: docker compose -f compose.selfhost.yaml exec portfolio-app node maintenance.mjs import-legacy ``` The import is transactional and only replaces untouched samples. It refuses to run twice or overwrite edits. Creating the owner account does not prevent import. The source fixture is `src/seed/legacy.json`; startup never imports it implicitly. ## Backups and restore Back up before every upgrade. Use the maintenance command, which acquires the same lock as content and upload writes, uses SQLite's backup API, copies uploads and the generated secret, then writes a completion manifest. A backup without `manifest.json` is incomplete. Do not copy only a live `.sqlite` file: committed changes may still be in its WAL file. ```sh npm run maintenance -- backup /srv/backups/portfolio-2026-09-15 ``` The target must be new and outside the data directory. In Docker, mount a host backup directory at `/backups` in an override file, then run: ```sh docker compose -f compose.selfhost.yaml -f compose.backup.yaml exec portfolio-app node maintenance.mjs backup /backups/before-upgrade ``` Example `compose.backup.yaml`: ```yaml services: portfolio-app: volumes: - /srv/backups/portfolio:/backups ``` The mounted backup directory must also be writable by UID 1001. Move completed backups to separate storage using your existing backup system. No scheduler or additional service is installed by this project. Restore verifies database integrity and that every uploaded file exists. It restores into a **new directory**, revokes all sessions, and never overwrites a live database: ```sh npm run maintenance -- restore /srv/backups/portfolio-2026-09-15 /srv/portfolio/restored ``` Stop the application, update `DATA_DIR` or `PORTFOLIO_DATA_DIR` to the restored directory, fix ownership if necessary, and start the matching application image. Keep the old directory until the restored site has been verified. There is no persistent content cache to invalidate after a restore. If a process is forcibly killed during a save or backup, `content.lock` may remain. Stop all application and maintenance processes, remove only that lock file from the data directory, then restart. Do not remove it while a write or backup is running. ## Upgrades and the existing Gitea deployment Build and test a new image, back up, then replace the container using the same data volume. Never use `docker compose down -v` for an installation you intend to keep. Migrations are versioned under `migrations/`; generate new migrations with `npx drizzle-kit generate` during development and commit them. The existing `compose.yaml` retains its `caddy_proxy` network and adds a named `portfolio-data` volume. Create `/runtime.env` containing the real `SITE_URL=https://your-domain.example`; the release script passes it to Compose. Keep this file private and outside version control. Existing deployments must explicitly import the original portfolio into the new data volume before editing. Automatic rollback to an older image is disabled after a failed activation: startup may already have changed the database schema. To roll back, restore the pre-upgrade backup into a new volume/directory and activate the matching previous image. Retain both the failed data and the backup for diagnosis. ## Verification ```sh npm run test:content npm run lint npm run typecheck npm run build npm test npm run test:e2e ``` Browser tests provision a temporary owner in a temporary data directory. Their credentials are test fixtures, not production defaults. The container smoke test checks generic startup, packaged legacy import, localized pages, and image optimization. Run `scripts/smoke-container.sh IMAGE_TAG` against the built image.