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 deployrolls 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.
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.
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 need | Notes |
|---|---|---|
| 1 | A Linux server with a public IP | Ubuntu 22.04/24.04. Uncloud installs Docker for you if missing. |
| 2 | SSH access to the server as root | Uncloud manages the machine over SSH. |
| 3 | A repo with a working Dockerfile | Builds from the repo root. |
| 4 | A domain you control DNS for | For the public HTTPS hostname (this guide uses <app-host>). |
| 5 | Docker + the uc CLI on your machine | For local builds and managing the cluster. |
Values you'll collect
| Placeholder | What it is | Example |
|---|---|---|
<server-ip> | Public IPv4 of your server | 203.0.113.10 |
<app-name> | Local image name for your app | docs |
<app-host> | Public hostname for the site | docs.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.
uc and the daemon in lockstepUncloud 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
Arecord<app-host> → <server-ip>. - Many hosts on one server: a wildcard
Arecord*.example.com → <server-ip>— every future service resolves with no new record.
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-portsis Uncloud's extension.<app-host>:80/httpspublishes 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.platformshould match your server's architecture (linux/amd64for 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.
exec format erroruc 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
--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
| Symptom | Likely cause / fix |
|---|---|
Container crash-loops with exec format error | Image 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 content | Mutable :latest didn't trigger a roll. Add --recreate to uc deploy (Part 5). |
| HTTPS request hangs or cert never issues | Hostname 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 mismatch | CLI and daemon out of lockstep. brew upgrade uncloud, then upgrade the machine's daemon to match. |
| CI fails at SSH | DROPLET_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>