6.1 KiB
Deployment
For the portfolio builder's first installation, owner commands, persistent
SQLite storage, and backup/restore, see SELF_HOSTING.md.
Set the public SITE_URL in <DEPLOY_PATH>/runtime.env before deployment.
The Gitea Actions workflow in .gitea/workflows/ci-cd.yml verifies every pull
request and every push to main. A successful push to main builds one
verified Docker image on the shared host daemon, smoke-tests it, and activates
that exact commit-tagged image over SSH without uploading source or rebuilding.
Release manifests are stored in <DEPLOY_PATH>/releases/<commit-sha>. Each
manifest records the immutable image tag and Gitea run number. The current
symlink is changed only after the new container is healthy. If startup fails,
the deployment script stops short of automatic rollback: startup may have
migrated the database. Restore the pre-upgrade backup with the matching older
image using the procedure in SELF_HOSTING.md.
After health succeeds, the Git checkout at <DEPLOY_PATH> is fast-forwarded to
the exact deployed commit. Deployment refuses to overwrite tracked server-side
changes or a checkout that has diverged from origin/main.
Gitea Actions setup
Enable Repository Actions in the repository settings and register the
dedicated trusted runner maintained in
https://git.amiirkhl.ir/amiirkhl/portfolio-gitea-runner. The
workflow uses the portfolio-ci label. Its reusable job image provides Node.js
22, Bash, Git, OpenSSH, Docker CLI, Buildx, and Compose. Runner capacity remains
one because Gitea, builds, and production share a 2-core/4-GB server.
The runner and job container use the host Docker socket. Restrict the label to this trusted repository and disable Actions for fork pull requests. The workflow also skips pull requests whose head repository differs from this repository.
After the first successful verification run, protect main in Gitea and make
the Verify production build status check required. This prevents a pull
request from merging when linting, type-checking, tests, the production build,
or the dependency audit fails.
Gitea repository secrets
Add these secrets under the repository's Settings > Actions > Secrets:
| Secret | Value |
|---|---|
DEPLOY_HOST |
Server hostname or IP address |
DEPLOY_USER |
SSH user allowed to run Docker |
DEPLOY_PORT |
SSH port; optional, defaults to 22 |
DEPLOY_PATH |
Absolute server directory, for example /srv/portfolio |
DEPLOY_SSH_KEY |
Private SSH key for the deployment user |
DEPLOY_KNOWN_HOSTS |
Pinned SSH host-key line for the server |
Generate the known-hosts value from a trusted machine and verify its fingerprint before saving it:
ssh-keyscan -p 22 your-server.example.com
Do not copy an unverified key directly into the secret; doing so would remove the protection against a man-in-the-middle attack.
One-time server setup
The server needs Docker Engine with the Compose v2 and Buildx plugins. The
runner and deployment user must reach the same Docker daemon, and the deployment
user must run docker without an interactive password prompt.
Create the deployment root and the external network once:
sudo mkdir -p /srv/portfolio/releases
sudo chown -R deploy:deploy /srv/portfolio
docker network create caddy_proxy
Replace deploy:deploy and /srv/portfolio with the values used for
DEPLOY_USER and DEPLOY_PATH. Add the public half of DEPLOY_SSH_KEY to the
deployment user's ~/.ssh/authorized_keys.
The standalone Next.js container joins caddy_proxy under the portfolio-app
service name. Caddy must also join that network and proxy to
portfolio-app:3000. No host port is published by this project.
A minimal Caddy route is:
portfolio.example.com {
reverse_proxy portfolio-app:3000
}
Caddy forwards the browser's Accept header by default; keep that behavior so
Next.js can negotiate WebP responses. Do not replace the cache headers emitted
for /_next/image. The named next-image-cache volume persists .next/cache
between container replacements so optimized variants can be reused.
The application expects immutable media URLs. When an admin or future upload workflow replaces an image, write it to a UUID or content-versioned URL instead of overwriting the previous object at the same URL. Next.js has no per-image optimizer-cache invalidation API.
Manual deployment
For an emergency manual deployment from a checkout on the server:
docker compose \
--project-name portfolio \
--file compose.yaml \
--file compose.build.yaml \
up --detach --build --remove-orphans
This legacy path builds amiirkhl-portfolio:latest and is intentionally kept
outside the normal CI deployment. Existing source-based release directories can
still be started with their original Compose file during migration. Rollback is
manual: verify schema compatibility or restore the pre-upgrade backup together
with its matching application image. Older release directories do not contain
release.env image metadata.
Pipeline timing verification
After migrating the runner, record one cold run and three normal source-change
runs. Expand the Set up job and Complete job sections in Gitea when
capturing the results. For each run, record checkout, verification build, audit,
runtime image creation, smoke test, deployment health wait, and completion time.
All three warm runs should finish below 180 seconds without OOM events or
production health failures. If they do not, collect docker stats, free -h,
df -h, docker system df, docker buildx du --builder portfolio-builder, and
the runner logs before changing or removing any quality gate.
Old versioned release directories are deliberately retained for rollback. They
can be removed manually after a newer release has been proven stable; keep the
directory referenced by the current symlink.
Runtime verification
The production image contains .next/standalone, .next/static, and public,
and runs the generated server.js with Node.js 22 on port 3000. CI smoke-tests
the English and Persian routes, a built static asset, and /_next/image with
WebP negotiation before deployment.