Skip to content

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 staging branch — 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:

sh
git checkout staging
git merge main
git push origin staging

GitHub Actions picks up the push and starts the deploy job automatically. Monitor it at:

https://github.com/Sema-Link/semalink-api/actions

GitHub Environment — staging

The workflow uses environment: staging, which:

  1. Loads secrets scoped to the staging environment (not repository-level secrets)
  2. Enforces any environment protection rules configured on GitHub (e.g. required reviewers)
  3. 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

yaml
- uses: actions/checkout@v4

Checks 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

yaml
- name: Extract commit subject
  id: commit
  run: echo "subject=$(git log -1 --format='%s')" >> $GITHUB_OUTPUT

Captures 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.

yaml
- 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 ps

set -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:

sh
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>
ENVEOF

This means:

  • The droplet holds no persistent secret state.env is always overwritten on deploy
  • Rotating a secret only requires updating the GitHub Environment secret and pushing to staging (or using workflow_dispatch)
  • The .env file on the droplet is a runtime artifact, not a source of truth

Docker build and restart

sh
docker compose -f docker-compose.staging.yml build
docker compose -f docker-compose.staging.yml up -d
docker compose -f docker-compose.staging.yml ps
  • build rebuilds the api image from the Dockerfile (Caddy and RabbitMQ use pre-built images and are not rebuilt)
  • up -d creates or recreates containers as needed; existing containers for unchanged services are left running
  • ps prints 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:

sh
npx drizzle-kit migrate
exec node dist/server.js

There 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

SecretPurpose
STAGING_DROPLET_IPDroplet public IPv4 — SSH connection target
STAGING_SSH_KEYED25519 private key for the deploy user
SLACK_WEBHOOK_URLIncoming webhook URL for deployment notifications

Environment secrets (staging)

SecretPurpose
DATABASE_URLNeon Postgres connection string for the staging database branch
REDIS_URLUpstash Redis TLS URL
RABBITMQ_PASSPassword for the RabbitMQ semalink user
JWT_SECRETHMAC secret for access token signing
JWT_REFRESH_SECRETHMAC secret for refresh token signing (must differ from JWT_SECRET)
MAILGUN_API_KEYMailgun private API key for transactional email
MAILGUN_DOMAINmailer.semalink.africa
MAILGUN_FROMnoreply@mailer.semalink.africa
CELCOM_API_URLCelcom Africa SMS gateway base URL
CELCOM_API_KEYCelcom 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 live

Rolling Back

There is no automated rollback. To roll back to a previous commit:

sh
git checkout staging
git revert <bad-commit-sha>   # creates a revert commit
git push origin staging

This 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:

  1. Provision a new droplet and run the one-time setup
  2. Create a GitHub Environment named test with its own secrets
  3. Copy deploy-staging.ymldeploy-test.yml, change branches: [staging][test] and update environment: stagingtest
  4. 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.

Internal use only — Sema Link Engineering