Skip to main content

HOW TO: Deploy a Container to a Server with Uncloud

This guide sets up a modern, lightweight deploy pipeline with Uncloud:

Push to main → GitHub Actions builds your Docker image → pushes it straight to your server via Uncloud's built-in image transfer (Unregistry) → uc deploy rolls the container zero-downtime, with automatic HTTPS from a built-in Caddy reverse proxy.

No external container registry, no hand-rolled reverse proxy, no manual TLS. Uncloud is a Docker-based platform that sits between "plain Docker" and Kubernetes: a decentralised cluster of one or more machines, Caddy for ingress with automatic Let's Encrypt, and deploys driven by standard Compose files.

Another way to do this

This is the approach this site uses. For a registry-based alternative that doesn't install a platform on the server — build → push to a private DigitalOcean registry → SSH-pull — see Publish to a DO Registry and Auto-Deploy.

Companion files (download)

Dockerfile · nginx.conf · .dockerignore · compose.yaml · deploy.yml · README

The Dockerfile/nginx.conf/.dockerignore are the same multi-stage static-site build used across these guides (Node build → nginx:alpine, ~20-30 MB image). compose.yaml and deploy.yml are the Uncloud-specific pieces.


Prerequisites

#You needNotes
1A Linux server with a public IPUbuntu 22.04/24.04. Uncloud installs Docker for you if missing.
2SSH access to the server as rootUncloud manages the machine over SSH.
3A repo with a working DockerfileBuilds from the repo root.
4A domain you control DNS forFor the public HTTPS hostname (this guide uses <app-host>).
5Docker + the uc CLI on your machineFor local builds and managing the cluster.

Values you'll collect

PlaceholderWhat it isExample
<server-ip>Public IPv4 of your server203.0.113.10
<app-name>Local image name for your appdocs
<app-host>Public hostname for the sitedocs.example.com

Part 1 — Install Uncloud

Step 1.1 — Install the uc CLI (your machine)

# macOS (Homebrew)
brew install psviderski/tap/uncloud

# or, any platform, via the install script
curl -fsS https://get.uncloud.run/install.sh | sh

Step 1.2 — Initialise the machine

This installs the Uncloud daemon on the server (over SSH), starts its Caddy reverse proxy, and saves a cluster context in ~/.config/uncloud/config.yaml:

uc machine init root@<server-ip>

Add --no-dns if you're bringing your own domain (as this guide does) rather than using an Uncloud-managed *.uncld.dev domain.

Keep uc and the daemon in lockstep

Uncloud versions before 1.0 can have breaking changes between the CLI and the daemon — the release notes will say "upgrade both to X.Y". After upgrading the uc CLI (brew upgrade uncloud), upgrade the machine's daemon to match. Check versions with uc machine ls (daemon) and brew list --versions uncloud (CLI).

Verify:

uc machine ls # machine 'Up', note the daemon VERSION column
uc ls # lists services (you'll see the built-in 'caddy')

Part 2 — Point DNS at the server

Uncloud's Caddy needs your hostname to resolve to the server so it can pass the Let's Encrypt HTTP challenge.

  • Single host: an A record <app-host> → <server-ip>.
  • Many hosts on one server: a wildcard A record *.example.com → <server-ip> — every future service resolves with no new record.
Cloudflare users

Use DNS-only (grey cloud), not proxied (orange), for the records Caddy will issue certs for — the proxy intercepts the HTTP-01 challenge otherwise. (Wildcard records can't be proxied below Enterprise anyway.)

Confirm it resolves before deploying:

dig +short <app-host> @1.1.1.1 # should print <server-ip>

Part 3 — The Compose file

In your repo root, create compose.yaml:

services:
app:
build: .
image: <app-name>:latest
platform: linux/amd64 # your server's architecture
x-ports:
- <app-host>:80/https # public hostname : container port
  • x-ports is Uncloud's extension. <app-host>:80/https publishes the service on that hostname over HTTPS and tells Caddy to route to container port 80 (where nginx serves the static site). No host ports are published — traffic only reaches the app through Caddy.
  • platform should match your server's architecture (linux/amd64 for most cloud droplets). See the architecture gotcha in Part 4.

Part 4 — First deploy (manual)

Deploy once by hand to confirm the whole path works before automating it.

uc build --push # build the image locally, push it to the machine
uc deploy # roll it out; Caddy fetches the TLS cert

Then check it:

curl -I https://<app-host>/ # expect: HTTP/2 200, valid cert (no -k needed)

Give Caddy a few seconds on the very first request while it obtains the certificate.

Architecture gotcha: exec format error

uc build builds for your machine's architecture. If you're on an Apple Silicon Mac (arm64) and your server is amd64, the pushed image won't run — you'll see the container crash-loop with exec /docker-entrypoint.sh: exec format error. It ignores the Compose platform: field and DOCKER_DEFAULT_PLATFORM for the build.

Fix — build explicitly for the server's arch, then push that image:

docker buildx build --platform linux/amd64 -t <app-name>:latest --load .
uc image push <app-name>:latest
uc deploy --no-build --recreate

This is only a concern for local builds from a mismatched machine. In CI (Part 5) the GitHub runner is amd64, matching a typical server, so a plain uc build --push just works.


Part 5 — Auto-deploy on push

Now wire GitHub Actions so every push to main deploys. Uncloud's management plane is SSH, so CI authenticates with a dedicated SSH deploy key.

Step 5.1 — Create a dedicated deploy key (your machine)

ssh-keygen -t ed25519 -f ~/.ssh/uncloud_ci_deploy -N "" -C "uncloud-ci-deploy"

Install the public half on the server, and verify it authenticates:

ssh root@<server-ip> "umask 077; mkdir -p ~/.ssh; cat >> ~/.ssh/authorized_keys" \
< ~/.ssh/uncloud_ci_deploy.pub

ssh -i ~/.ssh/uncloud_ci_deploy -o IdentitiesOnly=yes root@<server-ip> "echo OK"

Step 5.2 — Add the private key as a GitHub secret

Reads from the file so the key never lands in your shell history:

gh secret set DROPLET_SSH_KEY --repo <owner>/<repo> < ~/.ssh/uncloud_ci_deploy

Step 5.3 — Add the workflow

Create .github/workflows/deploy.yml (download: deploy.yml). Replace <server-ip> and confirm UC_VERSION matches your daemon:

name: Deploy to Uncloud

on:
push:
branches: [main]
workflow_dispatch: {}

env:
UC_VERSION: v0.20.0
MACHINE_HOST: <server-ip>

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5

- name: Install uc CLI (matched to the daemon)
run: |
curl -fsSL -o uc.tar.gz \
"https://github.com/psviderski/uncloud/releases/download/${UC_VERSION}/uc_linux_amd64.tar.gz"
tar -xzf uc.tar.gz uc
sudo install uc /usr/local/bin/uc
rm -f uc uc.tar.gz

- name: Configure SSH deploy key
run: |
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.DROPLET_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "${MACHINE_HOST}" >> ~/.ssh/known_hosts 2>/dev/null

- name: Build, push, and deploy
env:
UNCLOUD_CONNECT: ssh://root@${{ env.MACHINE_HOST }}
run: |
uc build --push
uc deploy --no-build --recreate -y
Deploy gotcha: --recreate is required

:latest is a mutable tag. uc deploy compares the service spec, sees the same :latest string, and decides "no change" — so a freshly pushed image does not roll. CI goes green while the site keeps serving the old container. Adding --recreate forces the container to be rebuilt from the newly pushed image every deploy (still zero-downtime, start-first). The alternative is immutable per-commit tags (<app-name>:${{ github.sha }}), which uc deploy would detect as a change on its own.

Commit and push to main, then watch it:

gh run watch "$(gh run list --branch main --limit 1 --json databaseId --jq '.[0].databaseId')" --exit-status

Troubleshooting

SymptomLikely cause / fix
Container crash-loops with exec format errorImage built for the wrong arch. Build for the server's arch (docker buildx build --platform linux/amd64 … --load) and uc image push. See Part 4.
CI is green but the site serves old contentMutable :latest didn't trigger a roll. Add --recreate to uc deploy (Part 5).
HTTPS request hangs or cert never issuesHostname doesn't resolve to the server, port 80 isn't reachable, or a Cloudflare-proxied (orange) record is intercepting the challenge. Verify dig, and set the record to DNS-only.
uc errors about version mismatchCLI and daemon out of lockstep. brew upgrade uncloud, then upgrade the machine's daemon to match.
CI fails at SSHDROPLET_SSH_KEY secret incomplete, or the public key isn't in the server's authorized_keys.

Quick Reference — the whole pipeline

┌─────────────┐ git push main ┌──────────────────┐
│ Your repo │ ────────────────► │ GitHub Actions │
└─────────────┘ └────────┬─────────┘
│ 1. install uc, load SSH deploy key
│ 2. uc build --push (Unregistry, over SSH)
│ 3. uc deploy --recreate

┌────────────────────────────┐
│ Uncloud machine │
│ ┌──────────┐ ┌─────────┐ │
│ │ Caddy │──►│ app │ │
│ │ 80 / 443 │ │ (nginx) │ │
│ └──────────┘ └─────────┘ │
│ auto Let's Encrypt TLS │
└────────────────────────────┘

https://<app-host>