Files
my-protfolio/SELF_HOSTING.md

12 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

With Docker Engine and Compose v2 installed, clone/pull this repository, copy .env.example to .env, set your public SITE_URL, then run:

docker compose up --build -d

The default named volumes are created with the required ownership automatically. The application initializes SQLite and its authentication secret, but never creates an owner on startup. When you want admin access, run:

docker compose exec portfolio-app node maintenance.mjs create-user
# create-owner remains an equivalent command.
docker compose exec portfolio-app node maintenance.mjs reset-password

The commands prompt for credentials. No owner credentials are needed in .env. Open /admin after creating the owner. For updates, pull and repeat the same Compose command. Never use docker compose down -v for data you intend to keep.

The default port binds to localhost. Set BIND_ADDRESS=0.0.0.0 to access it remotely, or point your existing HTTPS reverse proxy to 127.0.0.1:3000. Set PORT and SITE_URL together when using a different port. The proxy should replace forwarded IP headers with the actual client address.

For the existing Caddy Docker network, set COMPOSE_FILE=compose.yaml:compose.caddy.yaml in the Linux server's .env. Caddy must already belong to caddy_proxy and proxy to portfolio-app:3000. Standalone installations do not need that network or override.

Use a single application instance and local storage. SQLite is not intended for multiple replicas or a network filesystem. Startup applies committed migrations. The image includes maintenance.mjs, so maintenance needs no npm installation. Baseline CPU support for image processing is retained; native compilation is cached separately and can make the first build slower. UI edits reuse dependency and native-library layers. Docker builds only the application, including Next.js TypeScript validation, and packages it; lint, audits, tests, and image probes do not run in the production build. Postbuild packages assets once.

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_VOLUME Persistent Docker volume name; preserve it on upgrades
PORTFOLIO_CACHE_VOLUME Docker image-cache volume name
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. Flags are automatic; optionally set Flag country to a lowercase country code (for example gb or us) to override the inferred region. Unknown regions show a globe. 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 private portfolio content

Personal content is supplied separately, never embedded in the application image. A version-1 bundle contains bundle.json with content (settings, languages, sections, entries) and files (original URL, relative file path, SHA-256, MIME, width, height). Keep the bundle outside the Git checkout.

npm run maintenance -- import-legacy /absolute/path/to/private-bundle

The import validates the bundle before writing, preserves project IDs, copies images into persistent uploads, preserves matching dark-mode image variants, and replaces only untouched sample content. It refuses repeated imports or edited portfolios. Creating an owner is independent.

For an existing, edited installation, migrate only references to the old bundled images before upgrading away from those assets:

npm run maintenance -- migrate-legacy-media /absolute/path/to/private-bundle

This operation preserves content and existing uploads and is safe to repeat. Back up first. For Docker, mount the external bundle read-only at /import and run node maintenance.mjs import-legacy /import (or migrate-legacy-media). Do not copy the bundle into the image or repository. Git history is unchanged.

create-user is an alias for create-owner; neither runs at application startup.

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

Upgrading an existing installation

Keep your existing data volume: the default portfolio_portfolio-data matches the previous deployment with project name portfolio. If yours differs, set PORTFOLIO_DATA_VOLUME to its existing name before starting the new Compose configuration. Preserve the existing authentication secret, including any BETTER_AUTH_SECRET you previously supplied. Copy SITE_URL from runtime.env into .env. Enable the optional Caddy override when retaining that network.

If you previously used the self-hosting bind mount, keep it through a local compose.override.yaml instead of accidentally starting an empty named volume:

services:
  portfolio-app:
    volumes:
      - /absolute/path/to/existing/data:/data

Keep its existing UID/GID 1001 ownership. Do not copy a live SQLite database. Back up before upgrading. To migrate old /images/... references, mount your external private bundle read-only in the same override:

services:
  portfolio-app:
    volumes:
      - /absolute/path/to/private-bundle:/import:ro

Combine both mounts in one volumes list if using both. Build the new image, then run the migration against the existing volume before replacing the server:

docker compose build
docker compose run --rm --no-deps portfolio-app node maintenance.mjs migrate-legacy-media /import
docker compose up -d

This is a one-time content migration, not a requirement for fresh installations. If startup fails after a schema migration, restore a pre-upgrade backup with the matching older image. Do not automatically downgrade the image over newer data. See DEPLOYMENT.md for the simplified Gitea workflow.

Loading feedback

Initial rendering and language changes use a full-page indicator. Case-study opening and next/previous navigation keep the drawer and its close control while showing a loading indicator inside it. Indicators follow navigation/rendering state without artificial delays or waiting for off-screen images and 3D scenes. They follow the current theme, honor reduced motion, and expose localized status labels under Languages in the editor.

Build checks

npm run typecheck
npm run build

The production build includes TypeScript validation. Browser tests and other optional test suites are not part of deployment.