Streamlined Deployments: GitLab CI/CD to DigitalOcean Droplets
Repeatable deployments are non-negotiable in serious production environments. Manually copying files or running commands via SSH quickly becomes a bottleneck—and a liability—as soon as more than one person or environment is involved.
Below: hardened process for deploying a typical Node.js service from GitLab to a DigitalOcean droplet using SSH, CI/CD variables, and pipeline scripting—without opening up your infrastructure any wider than necessary.
Problem: Manual Deployments Don’t Scale
Deploying code by hand, even for a “hobby” SaaS or API on DigitalOcean, invites drift: inconsistent code states, version mismatches, and that classic “it worked on staging” scenario. Additionally, human interaction means human error. Consider commits that never make it to production, differences in file permissions, or forgotten dependency installs.
Automation solves these. Standardize what happens and when, using GitLab’s pipelines.
Baseline Requirements
- DigitalOcean droplet (Ubuntu 20.04 LTS is a reasonable default)
- Deployment user account (not
root
) with SSH access - GitLab repository containing application code
- Familiarity with SSH keys and
.gitlab-ci.yml
semantics
For production, confirm you have proper firewall rules, non-root deployment user, and either a static IP or dynamic DNS bound to your droplet.
SSH Key Strategy for CI Job Access
Separate deployment keys by context. Don’t reuse personal keys for the pipeline—generate a dedicated key pair.
ssh-keygen -t rsa -b 4096 -C "gitlab-deploy@internal" -f gitlab_deploy_key -N ""
- Private key:
gitlab_deploy_key
(to be stored securely) - Public key:
gitlab_deploy_key.pub
(appended to deployed user's~/.ssh/authorized_keys
on the droplet)
Deploy with command (adjust username/IP appropriately):
ssh -i ./gitlab_deploy_key deployuser@X.X.X.X
If a Permissions are too open
error appears, fix file mode:
chmod 600 ~/.ssh/authorized_keys
Securing the Private Key in GitLab
Input the private key into your GitLab CI/CD variable store:
Settings
→CI/CD
→Variables
→ Add Variable- Key:
DEPLOY_SSH_KEY
- Value: contents of
gitlab_deploy_key
- Masked: True
- Protected: True (unless you expect to deploy from unprotected branches)
This minimizes exposure—private key never leaves GitLab’s encrypted backend.
Pipeline Example: .gitlab-ci.yml
A functional pipeline job should bring up SSH, inject the key, and deploy via either a git pull
or rsync
operation. Here, git-based updating is shown:
stages:
- deploy
deploy_prod:
stage: deploy
image: ubuntu:20.04
before_script:
- apt-get update -qq && apt-get install -y openssh-client git
- eval "$(ssh-agent -s)"
- echo "$DEPLOY_SSH_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh
# Accept droplet host key without prompt
- ssh-keyscan -H $DROPLET_IP >> ~/.ssh/known_hosts
script:
# Ensure repo is present on target; pull code, install/update dependencies, restart via PM2
- >
ssh deployuser@$DROPLET_IP '
cd /var/www/myapp &&
git fetch origin &&
git reset --hard origin/main &&
npm ci &&
pm2 reload ecosystem.config.js || pm2 start ecosystem.config.js'
only:
- main
variables:
DROPLET_IP: "203.0.113.10"
Key Points:
- Uses
git fetch
+reset --hard
for total codebase replacement—mitigates cases where untracked files accumulate. npm ci
overnpm install
for deterministic dependency installations.- Reloads or starts the PM2 job as appropriate (avoids “process not running” failures).
Known Issue:
If SSH fails with Host key verification failed
, clear old keys from ~/.ssh/known_hosts
or verify the droplet IP hasn’t changed.
Preparing the Deployment Target
Initial setup on droplet:
sudo adduser deployuser
sudo mkdir -p /var/www/myapp
sudo chown deployuser:deployuser /var/www/myapp
su - deployuser
cd /var/www/myapp
git init
git remote add origin git@gitlab.com:yourusername/your-repo.git
git fetch origin
git checkout main
- Node.js and PM2 must be installed here.
- Do not run deployments as root.
Practical tip:
Pin to LTS Node.js (nvm install --lts
)—mixing versions between local and production often causes subtle failures.
Variations: rsync Instead of git
Rsync eliminates the need for remote git credentials, but slightly increases pipeline runtime due to complete file syncing on every push:
script:
- rsync -az --delete --exclude='node_modules' ./ deployuser@$DROPLET_IP:/var/www/myapp/
- ssh deployuser@$DROPLET_IP 'cd /var/www/myapp && npm ci && pm2 reload ecosystem.config.js'
This pattern can be better for static sites, or when you can’t easily set up deploy keys for git pull.
Handling Multiple Environments
Parameterize jobs by defining DROPLET_IP
, branch names, and possibly command sets per target. Example matrix:
Environment | Branch | Variable | IP Address |
---|---|---|---|
staging | develop | DROPLET_IP | 203.0.113.21 |
production | main | DROPLET_IP | 203.0.113.10 |
Use separate pipeline jobs or environment keyword to direct deploys.
Conclusion (by the numbers)
- Pipeline deploys remove human drift and error.
- Trackable releases via GitLab UI and logs.
- Easy rollback: re-run previous pipeline or force a particular commit hash.
- Only SSH key exposure to GitLab—never store credentials in code.
Trade-off:
Every pipeline run with SSH key access can potentially deploy, so restrict protected branches rigorously. For advanced scenarios (containerized workloads), consider GitLab’s Kubernetes integration, but for VM-based applications on DigitalOcean, the above technique remains robust and maintainable—at least until you outgrow single-server architectures.
Gotcha:
Avoid relying on latest
Node.js or system package versions in production—package drift can be almost invisible until a deploy breaks at 2am. Pin versions both locally and in CI environments.
Questions about Dockerized workflows, zero-downtime configs with HAProxy, or scaling beyond one droplet? Real-world deployments rarely stop at basics—adapt and fiddle as required.