Skip to main content

HOW TO: Publish a Docker Image to a Private DigitalOcean Container Registry and Auto-Deploy to a Droplet

An alternative approach

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 Dockerfile in your repo.
  • Your app listens on some container port (this guide uses 8080 as 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.
Companion files (download)

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 needNotes
1A DigitalOcean accountFree registry tier is fine
2A GitHub repository with a DockerfileThe workflow builds from the repo root
3An Ubuntu droplet (any size)This guide assumes Ubuntu 22.04/24.04
4SSH access to the droplet as root (or a sudo user)Needed once, for setup
5doctl 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.

PlaceholderWhat it isWhere you get itYour value
<registry-name>The globally-unique name of your DO registryPart 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 dropletDO control panel
<deploy-user>Non-root user on the droplet that runs deploysYou create it: deploy
<app-port>The port your app listens on inside the containerYour Dockerfile
<host-port>The port on the droplet you want to exposeYou 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

  1. In the DO control panel go to Container Registry (under "Manage").
  2. Click Create Registry.
  3. Pick a name — this becomes <registry-name> and must be globally unique (it appears in the image URL).
  4. Choose the Starter (Free) plan: 1 repository, 500 MB.
  5. 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)

  1. DO control panel → APITokensGenerate New Token.
  2. Name it something like github-ci-registry-push.
  3. Set expiration to your preference (a long-lived token is fine for CI; rotate periodically).
  4. Under Scopes, choose Custom Scopes and grant the registry read and write scopes (search for registry).
    • If your account only offers full-access tokens, a full-access token works but is less ideal.
  5. 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-password means 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:

  1. Use a read-only token.
  2. Run docker login as 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 :latest tag. 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_keyprivate key → goes into a GitHub secret.
  • ~/.ssh/do_deploy_key.pubpublic 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 nameValue
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_KEYThe 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

  1. Checkout — pulls your repo (needed to build from the Dockerfile).
  2. Install doctl — installs DO's CLI and authenticates it with DO_REGISTRY_TOKEN.
  3. Log in to DO Container Registrydoctl registry login writes short-lived (20-minute) Docker credentials so the next steps can push.
  4. Build image — builds from the repo root and tags with both :latest and the commit SHA.
  5. Push image — uploads both tags to your private registry.
  6. Deploy to droplet — SSHes in as <deploy-user> and:
    • docker compose pull — fetches the new :latest image (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)

  1. 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 tags
    doctl registry repository list-tags <app-name>

    # Delete a specific tag
    doctl registry repository delete-tag <app-name> <the-sha-tag>
  2. 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 Dockerfile lean (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

SymptomLikely cause / fix
CI fails at Log in to DO Container RegistryDO_REGISTRY_TOKEN missing/expired or lacks write scope.
CI push fails with deniedToken lacks write scope, or <registry-name>/<app-name> typo in env:.
Deploy step: docker compose pull says unauthorized / no basic auth credentialsThe 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 SSHDocker not installed, or <deploy-user> not in the docker group (usermod -aG docker deploy, then reconnect).
Container pulls but old code still servesApp 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 pushYou'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/.env on 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 │
└────────────────────────────┘