Skip to content

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.