10 KiB
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, orpt-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 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, 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.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.