VPS Setup & CI/CD
Image publishing
On every push to the main branch (or manual trigger), a two-job pipeline builds and publishes the Docker image:
build— compiles the JAR via Maven only, no Docker involved, uploads it as a workflow artifact.push— downloads the JAR, builds a multi-arch image (linux/amd64andlinux/arm64, via Buildx + QEMU), and pushes both alatesttag and a commit-SHA tag to the registry.
Split into two jobs so the build step never needs registry credentials.
Multi-arch matters if your VPS provider’s free/cheap tier runs on ARM (e.g.
Oracle Cloud’s Always-Free Ampere shape) rather than the x86_64 a CI runner
builds on by default — pulling a single-arch amd64 image onto an arm64 host
fails immediately with exec format error.
Required secrets: a registry username and an access token (not your account password).
Deploying to the VPS
A second, manual-trigger-only workflow (workflow_dispatch) handles the
actual deploy — decoupled from image publishing, so pushing a new image
never auto-deploys. Triggering it runs, in order:
Ensure Docker is installed and current
SSHes in and installs Docker via the distro’s official apt repository (not a generic convenience install script — some of those install extra packages that aren’t published for every distro/architecture and fail the whole install). Upgrades it if already present.
Copy deploy files to the server
Copies docker-compose.yml, the logging shipper config, and the backup
script to a fixed directory on the VPS (e.g. ~/ranlanka).
Register the backup cron
Makes the backup script executable and installs its crontab entry idempotently (removes any existing entry for the same script first, so re-running the deploy doesn’t create duplicate cron lines).
Write .env and start the stack
Writes a fresh .env file on the VPS from GitHub secrets (chmod 600), then
runs docker compose pull && docker compose up -d — optionally with the
cloud-logging profile enabled, depending on a repo variable (see
Logging & Monitoring).
Required secrets:
| Secret | Purpose |
|---|---|
<HOST>_HOST | The instance’s public IP/hostname |
<HOST>_USER | SSH user (e.g. ubuntu for stock Ubuntu images) |
<HOST>_SSH_KEY | Full contents of the private key, pasted as-is |
<HOST>_SSH_PORT | Optional, defaults to 22 |
DB_USERNAME / DB_PASSWORD | Production database credentials |
APP_JWT_SECRET | Must be a real generated key, never the repo-default placeholder |
SPRING_MAIL_USERNAME / SPRING_MAIL_PASSWORD | SMTP credentials |
APP_EXCHANGE_RATE_API_KEY | Only needed for non-default-currency quotes/invoices |
APP_FRONTEND_URL, APP_CORS_ALLOWED_ORIGINS | Real production origins |
The real .env never needs to exist in git or be hand-created on the
server — the workflow writes it fresh on every run.
A fresh cloud instance’s default firewall typically only opens the SSH port. Opening the ports your app actually needs (HTTP/HTTPS, and temporarily the app’s own port for direct testing) is a one-time manual step in both the cloud provider’s console and the instance’s own OS firewall — see the two-layer firewall note in Networking & Firewalls.
Local Docker workflow
# Configure environment
cp .env.example .env
# fill in DB credentials, JWT secret, mail credentials, exchange-rate key
# Start the stack (API + Postgres)
docker compose up -d
# Stop, keeping data volumes
docker compose down
# Stop and WIPE data volumes - destructive, confirm scope first
docker compose down -vTo run a published image instead of building locally, set the image
reference in .env before starting — compose pulls it directly rather than
building from source.
Operational commands
# Redeploy the latest published image without a full workflow run
cd ~/ranlanka
sudo docker compose pull
sudo docker compose up -d
# Full clean restart (also re-reads docker-compose.yml/.env)
sudo docker compose down
sudo docker compose up -d
# Remove old/unused images after a redeploy
sudo docker image prune -fDirect database access (inside the running Postgres container):
# Open a psql shell
sudo docker exec -it <db-container> psql -U postgres -d postgres
# One-off query without an interactive shell
sudo docker exec -it <db-container> psql -U postgres -d postgres -c \
"SET search_path TO <schema>; SELECT email, is_active FROM staff;"
# Check which Flyway migration is currently applied
sudo docker exec -it <db-container> psql -U postgres -d postgres -c \
"SET search_path TO <schema>; SELECT version, description, success FROM flyway_schema_history ORDER BY installed_rank DESC LIMIT 5;"Full purge (docker compose down -v + docker system prune -a -f --volumes) stops and removes every container, network, and named
volume — this wipes the production database and uploaded files. Only
ever run this deliberately, with the scope fully understood, never as a
reflexive “reset everything” step.
Git hooks (Husky)
The backend has its own hooks-only package.json (not a runtime dependency
of the Spring Boot app). After cloning, run npm install once to register:
- pre-commit — runs the full backend test suite. No database required (no live Spring context in any test), so this is fast and safe on every commit.
- pre-push — a full build, as a final sanity check before code leaves your machine.