HOW TO: Publish a Docker Image to a Private DigitalOcean Container Registry and Auto-Deploy to a Droplet
This is the registry-based approach: build → push to a private registry → SSH-pull on the server. It installs no platform on the box and works anywhere, but you wire up the reverse proxy and HTTPS yourself. This site now deploys with Uncloud instead, which bundles ingress, automatic HTTPS, and image transfer. Pick whichever fits your setup.
This guide sets up a full pipeline:
Push to
main→ GitHub Actions builds your Docker image → pushes it to a private DigitalOcean Container Registry → SSHes into your Ubuntu droplet → the droplet pulls the new image and restarts the container.
It targets the free "Starter" tier of DO's Container Registry (1 repository, 500 MB storage) and a single Ubuntu droplet running the container via Docker Compose.
Assumptions:
- You already have a working
Dockerfilein your repo. - Your app listens on some container port (this guide uses
8080as a placeholder — change it to yours). - HTTPS / reverse proxy is out of scope (see Next Steps); the container's port is mapped straight to a droplet port.
Grab ready-to-use copies of every file in this guide: Dockerfile · nginx.conf · .dockerignore · deploy.yml · docker-compose.yml · README
These are Docusaurus-tailored starter files — a multi-stage Dockerfile (Node build → nginx serve, final image ~20-30 MB), nginx.conf, .dockerignore, the workflow deploy.yml, and the droplet docker-compose.yml. If you're deploying a static site like Docusaurus, use this multi-stage Dockerfile instead of writing your own. See the downloaded README for where each file goes.
Prerequisites
| # | You need | Notes |
|---|---|---|
| 1 | A DigitalOcean account | Free registry tier is fine |
| 2 | A GitHub repository with a Dockerfile | The workflow builds from the repo root |
| 3 | An Ubuntu droplet (any size) | This guide assumes Ubuntu 22.04/24.04 |
| 4 | SSH access to the droplet as root (or a sudo user) | Needed once, for setup |
| 5 | doctl installed locally (optional but handy) | Install guide |
Values you'll collect along the way
Fill these in as you go — you'll reference them in later steps and in the workflow file.
| Placeholder | What it is | Where you get it | Your value |
|---|---|---|---|
<registry-name> | The globally-unique name of your DO registry | Part 1 | |
<app-name> | The image/repository name (Starter tier = 1 repo) | You choose it | |
<do-write-token> | DO API token with registry read+write (for CI to push) | Part 1 | |
<do-read-token> | DO API token with registry read-only (for the droplet to pull) | Part 2 | |
<droplet-ip> | Public IPv4 of your droplet | DO control panel | |
<deploy-user> | Non-root user on the droplet that runs deploys | You create it: deploy | |
<app-port> | The port your app listens on inside the container | Your Dockerfile | |
<host-port> | The port on the droplet you want to expose | You choose (e.g. 80) |
The full image path you'll use everywhere is:
registry.digitalocean.com/<registry-name>/<app-name>
Part 1 — Create the DigitalOcean Registry
Step 1.1 — Create the registry
- In the DO control panel go to Container Registry (under "Manage").
- Click Create Registry.
- Pick a name — this becomes
<registry-name>and must be globally unique (it appears in the image URL). - Choose the Starter (Free) plan: 1 repository, 500 MB.
- Choose a datacenter region (ideally the same region as your droplet).
Your registry hostname is always registry.digitalocean.com. Your images live under registry.digitalocean.com/<registry-name>/.
Note on the free tier: Starter allows exactly one repository. So you get a single
<app-name>(e.g.web). All your tags (:latest,:<git-sha>, …) live under that one repo and share the 500 MB budget. See Part 6 for keeping under the cap.
Step 1.2 — Understand the two-token approach (least privilege)
We create two separate API tokens so each side has only the access it needs:
- CI push token (
<do-write-token>) — registry read + write. Lives only in GitHub secrets. - Droplet pull token (
<do-read-token>) — registry read-only. Lives only on the droplet.
This way a leaked droplet can never push or delete images, and CI never needs droplet credentials.
Step 1.3 — Create the CI push token (read+write)
- DO control panel → API → Tokens → Generate New Token.
- Name it something like
github-ci-registry-push. - Set expiration to your preference (a long-lived token is fine for CI; rotate periodically).
- Under Scopes, choose Custom Scopes and grant the registry
readandwritescopes (search forregistry).- If your account only offers full-access tokens, a full-access token works but is less ideal.
- Copy the token now — you can't see it again. This is
<do-write-token>.
(We'll create the read-only droplet token in Part 2.)
Part 2 — Prepare the Droplet
SSH into your droplet as root (or a sudo user) for this part:
ssh root@<droplet-ip>
Step 2.1 — Install Docker Engine + the Compose plugin
Use Docker's official install script (simplest for a fresh Ubuntu box):
# Install Docker Engine + CLI + Compose plugin
curl -fsSL https://get.docker.com | sh
# Verify
docker --version
docker compose version
docker compose version (note: no hyphen) confirms the Compose v2 plugin is installed — that's the one we use.
Step 2.2 — Create a non-root deploy user
CI will SSH in as this user. It should be able to run Docker but not be root.
# Create the user (this becomes <deploy-user>)
adduser --disabled-password --gecos "" deploy
# Let it run Docker without sudo
usermod -aG docker deploy
--disabled-passwordmeans the user has no login password — CI authenticates with an SSH key (set up in Part 3). That's what we want.
Step 2.3 — Create the app directory
mkdir -p /opt/app
chown deploy:deploy /opt/app
Step 2.4 — Log the droplet into the registry (read-only)
The droplet needs credentials so docker compose pull works unattended. Two important rules:
- Use a read-only token.
- Run
docker loginas the<deploy-user>— Docker stores credentials in that user's~/.docker/config.json, and CI's deploy commands run as that same user, so they must match.
Create the read-only token: repeat Step 1.3 but grant only the registry read scope. Name it droplet-registry-pull. This is <do-read-token>. Prefer no expiration (or a long one) so unattended pulls don't suddenly start failing.
Log in as the deploy user:
# Switch to the deploy user
su - deploy
# DO accepts the API token as BOTH the username and password
echo "<do-read-token>" | docker login registry.digitalocean.com \
--username "<do-read-token>" \
--password-stdin
# You should see: Login Succeeded
exit # back to root
Why token-as-username-and-password? DigitalOcean's registry authenticates by passing the API token in both fields — there's no separate username.
Step 2.5 — Create the Compose file
As the deploy user, create /opt/app/docker-compose.yml:
su - deploy
nano /opt/app/docker-compose.yml
services:
app:
image: registry.digitalocean.com/<registry-name>/<app-name>:latest
restart: unless-stopped
ports:
# <host-port>:<app-port> → droplet port : container port
- "<host-port>:<app-port>"
# Optional: load environment variables from /opt/app/.env
env_file:
- .env
If you don't use env vars yet, delete the env_file: block, or create an empty .env:
touch /opt/app/.env
The running container always tracks the
:latesttag. CI also pushes a:<git-sha>tag for traceability and manual rollback (see Rollback).
Don't start it yet — there's no image in the registry until CI runs. Exit back to root:
exit
Part 3 — Set Up SSH Access for CI
CI needs to SSH into the droplet as <deploy-user>. We use a dedicated keypair (don't reuse your personal key).
Step 3.1 — Generate a keypair (on your local machine)
ssh-keygen -t ed25519 -f ~/.ssh/do_deploy_key -C "github-actions-deploy" -N ""
This creates:
~/.ssh/do_deploy_key— private key → goes into a GitHub secret.~/.ssh/do_deploy_key.pub— public key → goes on the droplet.
Step 3.2 — Install the public key on the droplet
Copy the contents of ~/.ssh/do_deploy_key.pub and add it to the deploy user's authorized keys:
# On the droplet, as root:
mkdir -p /home/deploy/.ssh
nano /home/deploy/.ssh/authorized_keys # paste the .pub contents, save
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys
Step 3.3 — Test it (from your local machine)
ssh -i ~/.ssh/do_deploy_key deploy@<droplet-ip> "docker ps"
If that connects and runs without a password prompt, SSH is ready.
Part 4 — Add GitHub Secrets
In your GitHub repo: Settings → Secrets and variables → Actions → New repository secret. Add these four:
| Secret name | Value |
|---|---|
DO_REGISTRY_TOKEN | <do-write-token> (the read+write token from Step 1.3) |
DROPLET_HOST | <droplet-ip> |
DROPLET_USER | <deploy-user> (e.g. deploy) |
DROPLET_SSH_KEY | The entire contents of ~/.ssh/do_deploy_key (the private key, including the -----BEGIN/END----- lines) |
Careful with the private key: paste it exactly as-is, including the header/footer lines and the trailing newline.
Part 5 — The GitHub Actions Workflow
Create .github/workflows/deploy.yml in your repo:
name: Build and Deploy
on:
push:
branches: [main]
env:
REGISTRY: registry.digitalocean.com
REGISTRY_NAME: <registry-name>
IMAGE_NAME: <app-name>
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Installs doctl and authenticates it with your write token
- name: Install doctl
uses: digitalocean/action-doctl@v2
with:
token: ${{ secrets.DO_REGISTRY_TOKEN }}
# Logs Docker into the DO registry using short-lived credentials
- name: Log in to DO Container Registry
run: doctl registry login --expiry-seconds 1200
- name: Build image
run: |
docker build \
-t $REGISTRY/$REGISTRY_NAME/$IMAGE_NAME:latest \
-t $REGISTRY/$REGISTRY_NAME/$IMAGE_NAME:${{ github.sha }} \
.
- name: Push image
run: |
docker push $REGISTRY/$REGISTRY_NAME/$IMAGE_NAME:latest
docker push $REGISTRY/$REGISTRY_NAME/$IMAGE_NAME:${{ github.sha }}
# SSHes into the droplet and rolls the container to the new image
- name: Deploy to droplet
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.DROPLET_HOST }}
username: ${{ secrets.DROPLET_USER }}
key: ${{ secrets.DROPLET_SSH_KEY }}
script: |
cd /opt/app
docker compose pull
docker compose up -d
docker image prune -f
What each step does
- Checkout — pulls your repo (needed to build from the
Dockerfile). - Install doctl — installs DO's CLI and authenticates it with
DO_REGISTRY_TOKEN. - Log in to DO Container Registry —
doctl registry loginwrites short-lived (20-minute) Docker credentials so the next steps can push. - Build image — builds from the repo root and tags with both
:latestand the commit SHA. - Push image — uploads both tags to your private registry.
- Deploy to droplet — SSHes in as
<deploy-user>and:docker compose pull— fetches the new:latestimage (using the droplet's read-only login from Step 2.4).docker compose up -d— recreates the container only if the image changed.docker image prune -f— removes now-dangling old image layers to save disk on the droplet.
First run
Commit and push to main. Watch the run under the repo's Actions tab. On success, verify on the droplet:
ssh -i ~/.ssh/do_deploy_key deploy@<droplet-ip> "docker ps"
curl http://<droplet-ip>:<host-port>
Part 6 — Free-Tier Housekeeping (500 MB)
The Starter tier gives you 500 MB. Every push adds image layers, and each unique :<git-sha> tag keeps a full manifest. Without cleanup you will hit the cap.
What uses space: deleting a tag doesn't immediately free space — the underlying blobs stick around as untagged manifests until you run garbage collection.
Periodic cleanup (manual)
-
Delete old SHA tags you no longer need for rollback (keep the last few). Via the control panel (Container Registry → your repo → delete old tags), or with
doctl:# List tagsdoctl registry repository list-tags <app-name># Delete a specific tagdoctl registry repository delete-tag <app-name> <the-sha-tag> -
Run garbage collection to actually reclaim the space from untagged manifests:
doctl registry garbage-collection start
Notes on GC:
- Only one garbage collection can run at a time.
- During GC the registry limits write access, so avoid pushing while it runs. Do it during a quiet window.
Reduce how fast you fill up
- Keep your
Dockerfilelean (multi-stage builds, small base images like-slim/alpine). - Consider not tagging every commit with a SHA if you rarely roll back — or prune SHA tags on a schedule.
Rolling Back
Because CI also pushes :<git-sha> tags, you can pin the droplet to a previous known-good build:
ssh deploy@<droplet-ip>
cd /opt/app
# Pull the specific older image and run it directly, OR
# temporarily point compose at the SHA by editing docker-compose.yml:
# image: registry.digitalocean.com/<registry-name>/<app-name>:<good-sha>
docker compose pull
docker compose up -d
Once you've verified a fix, push a new commit and the normal :latest flow resumes.
Troubleshooting
| Symptom | Likely cause / fix |
|---|---|
| CI fails at Log in to DO Container Registry | DO_REGISTRY_TOKEN missing/expired or lacks write scope. |
CI push fails with denied | Token lacks write scope, or <registry-name>/<app-name> typo in env:. |
Deploy step: docker compose pull says unauthorized / no basic auth credentials | The droplet's docker login wasn't run as <deploy-user>, or the read-only token expired. Re-run Step 2.4 as the deploy user. |
| Deploy step: Permission denied (publickey) | DROPLET_SSH_KEY secret is incomplete/mismatched, or the public key isn't in /home/deploy/.ssh/authorized_keys. |
docker: command not found over SSH | Docker not installed, or <deploy-user> not in the docker group (usermod -aG docker deploy, then reconnect). |
| Container pulls but old code still serves | App not tracking :latest, or the old container is cached — check docker compose up -d recreated it; confirm the pushed digest changed. |
| Registry out of space on push | You've hit 500 MB — see Part 6. |
Next Steps
- HTTPS / domain: put a reverse proxy in front (Caddy is the easiest for automatic Let's Encrypt certs; Nginx + certbot also works). Add it as another service in
docker-compose.yml. - Build caching: speed up CI with Docker layer caching (
docker/build-push-action+ GitHub Actions cache). - Zero-downtime deploys: for a single droplet, look at Compose's
pull+ rolling restart, or move to multiple replicas behind the proxy. - Secrets management: keep app secrets in
/opt/app/.envon the droplet (never in the image), or use DO's env management.
Quick Reference — the whole pipeline
┌─────────────┐ git push main ┌──────────────────┐
│ Your repo │ ────────────────► │ GitHub Actions │
└─────────────┘ └────────┬─────────┘
│ 1. doctl registry login (write token)
│ 2. docker build (:latest + :sha)
│ 3. docker push
▼
┌────────────────────────────┐
│ DO Container Registry (priv)│
│ registry.digitalocean.com │
└────────────┬───────────────┘
│ SSH (deploy key)
▼
┌────────────────────────────┐
│ Ubuntu Droplet │
│ cd /opt/app │
│ docker compose pull │ ◄── read-only token login
│ docker compose up -d │
│ docker image prune -f │
└────────────────────────────┘