Deployment Pipeline
This document is the source of truth for the GitLab deployment flow.
Secret source
The deployment pipeline supports two modes:
- preferred: SOPS-encrypted env files committed in the repository
- fallback: environment-scoped GitLab CI variables rendered into
.env
Priority order:
infra/env/{environment}.env.enc- scoped GitLab CI variables
Environments
staging- deployed automatically on every push to
main - used to validate the current
mainstate before production rollout
- deployed automatically on every push to
production- deployed manually from the
mainpipeline - intended as an explicit promotion step after staging validation
- deployed manually from the
GitLab jobs
deploy:staging- environment:
staging - resource group:
staging - trigger: automatic on
main
- environment:
deploy:prod- environment:
production - resource group:
production - trigger: manual on
main
- environment:
Both jobs reuse the same deployment flow:
- sync application sources to the target server
- decrypt
infra/env/{environment}.env.encor generate.envfrom CI variables - validate env with
scripts/check-env.sh - run
infra/deploy.sh - run
infra/migrate-improved.sh
SOPS setup
Required in CI when using encrypted env files:
- preferred:
SOPS_AGE_KEY_B64- base64-encoded private age key content on one line
- recommended for GitLab variables
- fallback:
SOPS_AGE_KEY- raw multiline private age key content
Generate SOPS_AGE_KEY_B64 with:
base64 < ~/.config/sops/age/aaperture.txt | tr -d '\n'
Repository files:
.sops.yamlinfra/env/staging.env.encinfra/env/production.env.enc
Helper files:
infra/env/README.mdscripts/bootstrap-sops-config.shscripts/render-deploy-env.shscripts/export-gitlab-env.mjsMakefile(make sops-decrypt-*,make sops-encrypt-*,make sops-edit-*)
SSH deploy key in GitLab
Recommended CI variable:
SSH_PRIVATE_KEY_B64- base64-encoded SSH private key on a single line
- avoids multiline formatting issues in GitLab variables
Generate the recommended base64 value with:
base64 < ~/.ssh/<your-deploy-key> | tr -d '\n'
Variable strategy
Use GitLab environment-scoped variables with the same variable names for both environments.
Examples of variables that should be scoped separately for staging and production:
SERVER_HOSTSERVER_USERSERVER_APP_DIRCOMPOSE_PROJECT_NAMESSH_PRIVATE_KEY_B64DOMAINNAMEFRONTEND_URLFRONTEND_BASE_URLAPI_BASE_URLPUBLIC_ROOT_DOMAINALLOWED_ORIGINSGOOGLE_CLIENT_IDGOOGLE_CLIENT_SECRETGOOGLE_CALLBACK_URLPOSTGRES_DBPOSTGRES_USERPOSTGRES_PASSWORDPOSTGRES_HOST_PORTAPI_HOST_PORTWEB_HOST_PORTPROMETHEUS_HOST_PORTGRAFANA_HOST_PORTR2_*STRIPE_*SENTRY_*VAPID_*
Recommendation:
- keep shared secrets global only when they are truly identical across environments
- scope host/domain/database credentials per environment
- prefer
SERVER_APP_DIRover legacy aliases in new configuration - keep distinct host ports when
stagingandproductionrun on the same server
Expected URLs
Staging should use its own dedicated domains, for example:
- marketing:
https://staging.aaperture.com - app:
https://app.staging.aaperture.com - tenant:
https://{slug}.staging.aaperture.com
Production keeps:
- marketing:
https://aaperture.com - app:
https://app.aaperture.com - tenant:
https://{slug}.aaperture.com
The exact staging domains are up to infrastructure, but they must be reflected consistently in:
DOMAINFRONTEND_URLFRONTEND_BASE_URLAPI_BASE_URLPUBLIC_ROOT_DOMAIN- OAuth redirect URIs
- DNS and certificates
Runtime isolation on the same server
If staging and production run on the same host, they must not share:
SERVER_APP_DIRCOMPOSE_PROJECT_NAME- Postgres data directory
- published host ports
Recommended staging values:
SERVER_APP_DIR=/var/www/aaperture-stagingCOMPOSE_PROJECT_NAME=aaperture-stagingPOSTGRES_HOST_PORT=15432API_HOST_PORT=18080WEB_HOST_PORT=18081PROMETHEUS_HOST_PORT=19090GRAFANA_HOST_PORT=13001
Recommended production values:
SERVER_APP_DIR=/var/www/aapertureCOMPOSE_PROJECT_NAME=aaperturePOSTGRES_HOST_PORT=5432API_HOST_PORT=8080WEB_HOST_PORT=8081PROMETHEUS_HOST_PORT=9090GRAFANA_HOST_PORT=3001
Rollout expectation
Recommended workflow:
- push to
main - let
deploy:stagingfinish automatically - validate staging manually
- trigger
deploy:prod
Current status:
stagingdeployment with SOPS: validatedproductiondeployment with SOPS: validated
Notes
NODE_ENVremainsproductionin both staging and production deploymentsAPP_ENV/ENVIRONMENTcarry the runtime label (staging,production, etc.)- backups, backup emails, and any runtime environment label must read
APP_ENV/ENVIRONMENT, neverNODE_ENV - migration script receives
ENVIRONMENT=stagingorENVIRONMENT=production - staging and production use different
resource_groupvalues, so they do not block each other unnecessarily
Ongoing maintenance
After SOPS becomes the source of truth:
- keep
infra/env/staging.env.encandinfra/env/production.env.enccommitted - do not keep
*.plain.envor*.from-gitlab.envin the repository - use GitLab scoped variables only for:
- deployment access
- bootstrap/migration fallback
SOPS_AGE_KEY_B64
Practical minimum GitLab set once the migration is complete:
SOPS_AGE_KEY_B64SERVER_HOSTSERVER_USERSERVER_APP_DIRSSH_PRIVATE_KEY_B64
Everything else should preferably come from:
infra/env/staging.env.encinfra/env/production.env.enc
When adding a new runtime variable:
- update
.env.example - update
scripts/check-env.shif required - update and re-encrypt both environment files
Recommended workflow:
make sops-decrypt-staging
make sops-encrypt-staging
make sops-decrypt-production
make sops-encrypt-production
Immediate priorities
The deployment stack is now stable enough to move away from deployment setup work.
Current priority order:
- keep
stagingas the default validation gate for every significant change - remove remaining runtime drift between
staging,production, and local development - improve pipeline/build efficiency only after measuring the real bottlenecks
- use the new platform baseline to validate public tenant, booking, portal, and email quality
Staging smoke test checklist
Keep this checklist as the standard regression pass for staging:
- marketing host:
https://staging.aaperture.com - app host:
https://app.staging.aaperture.com - tenant host:
https://{slug}.staging.aaperture.com - public booking flow
- client portal login flow
- public lead form flow
- Google OAuth callback on staging
- R2 uploads and public asset delivery
- Sentry event ingestion
- backup naming and backup notification email
Database/runtime checks:
- staging writes to
/var/www/aaperture-staging COMPOSE_PROJECT_NAME=aaperture-stagingPOSTGRES_DB=aaperture_stagingPOSTGRES_HOST_PORT=15432- migrations run against staging DB, not production
Production promotion checklist
Only trigger deploy:prod after:
deploy:stagingpassed on the same commit- staging smoke test is green
- encrypted env files were updated and committed if needed
- GitLab bootstrap variables still match production
- DNS / cert / Nginx remain valid for:
aaperture.comapp.aaperture.com*.aaperture.com
This checklist has already been validated once with the SOPS-based setup. Keep using it as the release gate for future production promotions.
Improvement plan
Short-term improvement topics for the deployment platform:
- add a documented smoke test runbook for staging and production
- measure pipeline duration by stage and introduce further optimizations only after measurement
- consider Docker layer caching if
build:webandbuild:apiremain slow - keep GitLab limited to bootstrap secrets only
- periodically rotate:
- deploy SSH key
- SOPS age key
- high-impact production secrets copied from legacy config
Application-adjacent priorities unlocked by the new staging setup:
- full staging validation of tenant/app/public multi-host behavior
- booking perceived performance and slot loading improvements
- transactional email visual QA across real providers