256 lines
11 KiB
Markdown
256 lines
11 KiB
Markdown
# 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
|
||
|
||
With Docker Engine and Compose v2 installed, clone/pull this repository, copy
|
||
`.env.example` to `.env`, set your public `SITE_URL`, then run:
|
||
|
||
```sh
|
||
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:
|
||
|
||
```sh
|
||
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.
|
||
|
||
## 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. 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.
|
||
|
||
```sh
|
||
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:
|
||
|
||
```sh
|
||
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.
|
||
|
||
```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.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.
|
||
|
||
## 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:
|
||
|
||
```yaml
|
||
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:
|
||
|
||
```yaml
|
||
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:
|
||
|
||
```sh
|
||
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.
|
||
|
||
## Build checks
|
||
|
||
```sh
|
||
npm run typecheck
|
||
npm run build
|
||
```
|
||
|
||
The production build includes TypeScript validation. Browser tests and other
|
||
optional test suites are not part of deployment.
|