Deployment
Releases follow the same local build → GitHub Container Registry (GHCR) → GitHub Actions → VPS flow as Diplodocus and Timethis. The publisher builds the middleware and documentation images from one committed revision. Actions reuses the successful push tests for that revision and checks the exact published images before deploying them. Production releases are manual; pushes and pull requests run tests only.
Installation
| Setting | Value |
|---|---|
| GitHub repository | peppermint-it/pepit |
| Server | 209.38.93.146, Linux amd64 |
| Deployment account | deployer |
| Runtime directory | /home/deployer/pepit |
| Compose project | pepit |
| Middleware image | ghcr.io/peppermint-it/pepit |
| Documentation image | ghcr.io/peppermint-it/pepit-docs |
| Middleware proxy port | 8080 |
| Documentation port | 8081 |
| Report cache volume | pepit_commercial-margin-cache |
| Offer draft volume | pepit_offer-drafts |
The server needs Docker, Compose v2 with --wait, Bash, Python 3, tar and flock.
The deployment account owns the runtime directory and belongs to the Docker
group. The release script requires the existing three running services and
both named volumes; it refuses unexpected app mounts instead of replacing them
with empty storage. There is brief downtime while containers are recreated.
Runtime credentials remain in the server's mode-0600 .env. Both
EXCEL_BASIC_USER and EXCEL_BASIC_PASS are required for authenticated container
health checks. A configured read-only /run/secrets/staff-costs.json mount is
preserved from the running container. Other custom mounts must be incorporated
into the versioned production configuration and validation before releasing.
docker-compose.production.yml is installed as docker-compose.yml on the
server. docker-compose.override.yml contains the selected images and any
preserved staff-cost mount. An unmanaged override blocks the first deployment.
The repository's docker-compose.yml remains the local build configuration.
Do not update the server runtime directory with Git or build images there.
Access setup
Configure these repository Actions secrets:
| Secret | Value or purpose |
|---|---|
SERVER_HOST |
209.38.93.146 |
SERVER_USER |
deployer |
DEPLOYER_SSH_KEY |
Dedicated SSH private key authorized for deployer |
KNOWN_HOSTS |
Verified SSH host-key entry for this server |
The publishing machine needs Docker Buildx and a GHCR login with permission to
publish both packages under peppermint-it. Use a classic personal access token
with write:packages; do not put it in Git or runtime .env. Both packages must
remain private and grant peppermint-it/pepit Actions read access in their package
settings. See GitHub's registry authentication and access guidance.
Actions uses its short-lived GITHUB_TOKEN with packages: read for image
checks and server pulls. The workflow sends that token through SSH into a
private temporary directory, uses a temporary Docker login, then removes it on
exit. No persistent registry token is needed for deployer. The workflow never
receives the server's runtime credentials. Registry publishing rights are trusted
release access; revision labels check consistency, not cryptographic provenance.
Publish and release
Commit tested changes and push main, then run from a clean checkout:
bash scripts/publish-images.sh
The publisher requires HEAD to match pushed origin/main. It builds an archive
of that commit for linux/amd64, labels both images with the full Git revision,
and checks them before publishing unique commit-based tags. Developer settings,
local exports and credentials never enter the archive. The middleware Dockerfile
copies an explicit set of runtime files; customer workbook imports, operational
snapshots and documentation sources are excluded from that image.
Image checks use disposable containers with no external network, host mounts
or published ports. The middleware starts its real Gunicorn entrypoint with
dummy Basic auth credentials and must serve authenticated /health. The docs
container must serve both /handbook/ and /systems/. Both documentation builds
also run in strict mode during image construction.
The publisher prints the two immutable digests and a check-only command:
gh workflow run release.yml --repo peppermint-it/pepit --ref main \
-f app_digest=sha256:APP_DIGEST -f docs_digest=sha256:DOCS_DIGEST \
-f check_only=true
Check the run with gh run list and gh run watch RUN_ID --exit-status, adding
--repo peppermint-it/pepit. Once checks pass, repeat the command with the same
digests and -f check_only=false. If main advances before dispatch, publish
that revision first. Do not rebuild between checking and releasing.
Push CI runs the Python suite, JavaScript checks invoked by the suite, both strict
documentation builds and shell checks. Both check-only and production releases
reuse the latest test.yml push run from this repository's main branch for
the exact release commit. The release waits up to ten minutes if CI is queued,
running or not yet visible; it never starts another test run. Failed, cancelled
or skipped CI blocks release, as do API errors and a timeout waiting for CI.
A newer failed run cannot be bypassed by an older successful result. Pull-request
results do not substitute for main push CI because their merge context differs.
Run local tests once for the change; reuse that result while the code and test environment are unchanged. Changing the commit requires its own push CI result. If dependencies or the test environment change without a new commit, explicitly rerun push CI before relying on its result. Re-dispatching check-only or production with the same commit does not repeat the suite. Image identity, image smoke checks and live health checks still run during each release because they validate the selected artifacts and runtime state.
Actions pulls and smoke-tests the exact digests. The VPS locks deployment, verifies the existing services and volumes, pulls the same images, checks their revision and architecture, and validates Compose. Check-only stops here without changing containers, runtime configuration, data or recovery files; downloaded images and the lock file can remain.
Production replacement retains the running app, docs and proxy images and a
private snapshot of the old configuration. It installs the release configuration,
recreates all three services with --no-build --pull never, waits for image
health, then checks authenticated /health through the internal Nginx proxy.
Recreating the proxy refreshes its upstream container address. The existing
proxy image is retained by image ID, so a release does not silently upgrade it.
Public HTTPS, proxy access policies, Zoho and Xero are outside these health checks.
See Docker's Compose startup options.
Recovery and retained state
Actions serializes releases; the server additionally holds .pepit-deploy.lock.
A failed preflight leaves production untouched. Once replacement starts,
.deploy/pending blocks another release until the failure has been reviewed.
The script does not automatically roll back data or remove volumes.
| Recovery item | Purpose |
|---|---|
pepit-app:previous, pepit-docs:previous, pepit-nginx:previous |
Images running before the latest production attempt |
.deploy/previous.compose.yml |
Resolved previous configuration, including private environment values |
.deploy/previous.images.yml |
Retained image references and staff-cost mount |
.deploy/previous.nginx.conf |
Previous proxy configuration |
.deploy/commit, .deploy/app-image, .deploy/docs-image |
Last successful release |
.deploy/pending |
Target commit of an incomplete deployment |
Recovery files are private (created with umask 077). Do not upload them, commit them, or prune retained images while they are needed. Existing cache and offer volumes are reused. These retained images/configuration are not data backups; back up persistent data and the private staff-cost file separately.
Inspect docker compose ps and container logs on the server. If the previous
application is compatible with current persistent data, restore the retained
configuration under the same deployment lock:
cd /home/deployer/pepit
(
flock -n 9 || exit 1
cp .deploy/previous.compose.yml docker-compose.yml
cp .deploy/previous.images.yml docker-compose.override.yml
cp .deploy/previous.nginx.conf nginx.conf
docker compose up --no-build --pull never --force-recreate --wait \
--wait-timeout 180 app docs nginx
) 9>.pepit-deploy.lock
A legacy image may not have a health check. Verify authenticated health through
the middleware proxy and both documentation routes explicitly. Record the restored
revision/images in .deploy/commit, .deploy/app-image and .deploy/docs-image,
then remove .deploy/pending under the lock only after recovery is verified.
A restored resolved Compose snapshot includes the old environment values; the
next normal release reinstates runtime .env loading. Do not edit .env expecting
to override the snapshot's explicit environment entries during recovery.
Keep .env, volumes, private configuration, installed Compose/proxy files and
.deploy/ when retiring the old server source checkout. Host reverse-proxy and
authentication policies remain separate; see documentation access.