Files
my-protfolio/SELF_HOSTING.md
Amirhossein Khalili 1f58461967
Some checks failed
Test and deploy / Verify production build (push) Failing after 15m48s
fix: build image libraries for baseline VPS CPUs
Replace the SIMD-dependent WebAssembly fallback with source-built libvips and Sharp. Pin the libvips archive checksum, use baseline x86-64 compiler flags, retain the native binding and runtime libraries, and check JPEG/PNG/WebP processing before compilation and in the runtime image.

Lint, type checking, and content/security tests passed. Full Docker verification was stopped at the user request; production build and baseline CPU runtime verification remain pending.
2026-09-15 23:16:54 +03:30

9.9 KiB
Raw Blame History

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:

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.

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.

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.

The Docker build compiles libvips and Sharp from source for baseline CPUs. This supports VPS hosts that lack the features required by Sharp's prebuilt native and WebAssembly binaries. The first build takes longer; Docker caches the image libraries for later application changes. Image processing is checked before Next.js compilation and again in the final runtime image. When upgrading Sharp, keep the libvips source version in the Dockerfile at or above Sharp's minimum.

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:

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.

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:

docker compose -f compose.selfhost.yaml -f compose.backup.yaml exec portfolio-app node maintenance.mjs backup /backups/before-upgrade

Example compose.backup.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:

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 <DEPLOY_PATH>/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

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.