API CI/CD Pipeline
This page documents the automated deployment pipeline for semalink-api to the staging droplet. Every push to the staging branch triggers a GitHub Actions workflow that SSHes into the droplet, pulls the latest code, rebuilds the Docker image, and restarts containers.
For the one-time droplet provisioning that must happen before this pipeline can run, see API Staging Setup.
Trigger
The workflow (.github/workflows/deploy-staging.yml) fires on:
- Push to
stagingbranch — the primary trigger - Manual dispatch (
workflow_dispatch) — useful for redeploying without a code change (e.g. after updating a secret)
Deploying to staging
Merge your changes into the staging branch:
git checkout staging
git merge main
git push origin stagingGitHub Actions picks up the push and starts the deploy job automatically. Monitor it at:
https://github.com/Sema-Link/semalink-api/actionsGitHub Environment — staging
The workflow uses environment: staging, which:
- Loads secrets scoped to the
stagingenvironment (not repository-level secrets) - Enforces any environment protection rules configured on GitHub (e.g. required reviewers)
- Records a deployment event visible in the repository's Environments tab
All API secrets (database, Redis, JWT, Mailgun, Celcom) live in this environment. Repository-level secrets (STAGING_DROPLET_IP, STAGING_SSH_KEY, SLACK_WEBHOOK_URL) are also accessible.
Pipeline Steps
Step 1 — Checkout
- uses: actions/checkout@v4Checks out the code at the pushed commit. Used only to extract the commit subject for the Slack notification — the actual build happens on the droplet.
Step 2 — Extract commit subject
- name: Extract commit subject
id: commit
run: echo "subject=$(git log -1 --format='%s')" >> $GITHUB_OUTPUTCaptures the first line of the commit message. Used in the Slack notification body.
Step 3 — Deploy via SSH
The core of the pipeline. Uses appleboy/ssh-action to SSH into the droplet as the deploy user and run a script.
- uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.STAGING_DROPLET_IP }}
username: deploy
key: ${{ secrets.STAGING_SSH_KEY }}
script: |
set -e
cd /opt/semalink-api
git pull origin staging
cat > .env <<'ENVEOF'
PORT=3000
...secrets expanded here...
ENVEOF
docker compose -f docker-compose.staging.yml build
docker compose -f docker-compose.staging.yml up -d
docker compose -f docker-compose.staging.yml psset -e means any command that exits non-zero immediately aborts the script and marks the step as failed.
Secret injection — writing .env from GitHub Secrets
GitHub Actions expands ${{ secrets.* }} references before the script is sent over SSH. The SSH action sends the fully-rendered script string to the droplet — the droplet never receives raw ${{ }} tokens.
The heredoc writes a fresh .env file to /opt/semalink-api/.env on every deploy:
cat > .env <<'ENVEOF'
PORT=3000
NODE_ENV=production
DATABASE_URL=postgresql://neondb_owner:...@ep-...neon.tech/neondb?sslmode=require&channel_binding=require
REDIS_URL=rediss://default:...@cheerful-panther-83349.upstash.io:6379
RABBITMQ_PASS=<expanded-from-secret>
JWT_SECRET=<expanded-from-secret>
JWT_REFRESH_SECRET=<expanded-from-secret>
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
APP_URL=https://staging-app.semalink.africa
MAILGUN_API_KEY=<expanded-from-secret>
MAILGUN_DOMAIN=mailer.semalink.africa
MAILGUN_FROM=noreply@mailer.semalink.africa
CELCOM_API_URL=https://api.celcomafrica.com
CELCOM_API_KEY=<expanded-from-secret>
ENVEOFThis means:
- The droplet holds no persistent secret state —
.envis always overwritten on deploy - Rotating a secret only requires updating the GitHub Environment secret and pushing to
staging(or usingworkflow_dispatch) - The
.envfile on the droplet is a runtime artifact, not a source of truth
Docker build and restart
docker compose -f docker-compose.staging.yml build
docker compose -f docker-compose.staging.yml up -d
docker compose -f docker-compose.staging.yml psbuildrebuilds theapiimage from the Dockerfile (Caddy and RabbitMQ use pre-built images and are not rebuilt)up -dcreates or recreates containers as needed; existing containers for unchanged services are left runningpsprints the current container status to the workflow log for quick sanity checking
Database migrations on startup
Migrations run automatically inside the container via scripts/entrypoint.sh:
npx drizzle-kit migrate
exec node dist/server.jsThere is no separate migration step in the CI/CD pipeline. If a migration fails, the api container exits (due to set -e), Docker restarts it (due to restart: unless-stopped), and the deployment is marked failed in GitHub Actions because the SSH script returns non-zero.
Slack Notifications
The workflow sends a Slack message on both success and failure to the #deployments channel.
Success message
Staging API Deployment Successful
Project: Sema Link API | Environment: Staging
Deployed by: <github username>
Commit: <sha>
Commit Message: <subject>
[Health Check →] (links to https://staging-arc.semalink.africa/health)Failure message
Staging API Deployment Failed
Project: Sema Link API | Environment: Staging
Triggered by: <github username>
Commit: <sha>
Commit Message: <subject>
[View Logs →] (links to the GitHub Actions run)The webhook URL is stored in the SLACK_WEBHOOK_URL repository-level secret (shared across environments).
Secrets Reference
Repository-level secrets
| Secret | Purpose |
|---|---|
STAGING_DROPLET_IP | Droplet public IPv4 — SSH connection target |
STAGING_SSH_KEY | ED25519 private key for the deploy user |
SLACK_WEBHOOK_URL | Incoming webhook URL for deployment notifications |
Environment secrets (staging)
| Secret | Purpose |
|---|---|
DATABASE_URL | Neon Postgres connection string for the staging database branch |
REDIS_URL | Upstash Redis TLS URL |
RABBITMQ_PASS | Password for the RabbitMQ semalink user |
JWT_SECRET | HMAC secret for access token signing |
JWT_REFRESH_SECRET | HMAC secret for refresh token signing (must differ from JWT_SECRET) |
MAILGUN_API_KEY | Mailgun private API key for transactional email |
MAILGUN_DOMAIN | mailer.semalink.africa |
MAILGUN_FROM | noreply@mailer.semalink.africa |
CELCOM_API_URL | Celcom Africa SMS gateway base URL |
CELCOM_API_KEY | Celcom Africa API key |
End-to-End Flow
Developer pushes to `staging`
↓
GitHub Actions starts deploy job (environment: staging)
↓
Checkout code → extract commit subject
↓
SSH into 209.38.197.79 as deploy user
↓
git pull origin staging
↓
Write .env from GitHub secrets
↓
docker compose build (rebuilds API image)
↓
docker compose up -d (recreates changed containers)
↓
Container startup:
entrypoint.sh runs drizzle-kit migrate
→ migrations succeed → node dist/server.js starts
↓
docker compose ps (printed to workflow log)
↓
Slack notification sent (success or failure)
↓
Done — staging-arc.semalink.africa is liveRolling Back
There is no automated rollback. To roll back to a previous commit:
git checkout staging
git revert <bad-commit-sha> # creates a revert commit
git push origin stagingThis triggers a new deploy of the reverted code. If the bad commit included a schema migration, you'll need to write a compensating migration — Drizzle does not support automated rollbacks.
Adding a New Environment (test / production)
To add a test environment:
- Provision a new droplet and run the one-time setup
- Create a GitHub Environment named
testwith its own secrets - Copy
deploy-staging.yml→deploy-test.yml, changebranches: [staging]→[test]and updateenvironment: staging→test - Add a DNS record
test-arc.semalink.africa → <new-droplet-ip>in Cloudflare
No changes to the Dockerfile or Compose file are needed — the environment differences are entirely in secrets and DNS.