Quick CI/CD Deployment for Solo Developers: GitHub Actions + Alibaba Cloud ACR + Servers Pulling Images Automatically
The most carefree deployment pipeline for a personal project: Actions builds the image and pushes it to Alibaba Cloud ACR, and watchtower on the server watches :latest and pulls updates automatically. Includes the complete workflow, the compose config, and how to handle dependency version mismatches, ACR rejecting provenance manifests, and multi-arch images.
For a small project maintained by one person, the most carefree deployment pipeline has three stages. Push code to GitHub; Actions builds the image and pushes it to Alibaba Cloud ACR; the container on the server discovers the new image on its own and updates.
Its advantage is not speed but that each stage can be swapped out independently. If GitHub goes down, the old image running on the server is unaffected; the historical tags kept in ACR let you roll back at any time; and the server side only cares about images, not what language or build tooling you use. Compared with the scp-a-jar approach, this adds a layer of image registry, and what it buys is a clean separation of build and runtime.
The whole pipeline looks like this.
flowchart LR
A[git push to master] --> B[GitHub Actions<br/>docker build]
B --> C[Push to Aliyun ACR<br/>latest + timestamp]
C --> D[Server watchtower<br/>polls for digest change]
D --> E[Pull new image<br/>recreate container]
What Does This Cost
Let’s do the math first: two of the three stages are free.
On the GitHub Actions side, builds on public repositories using standard runners cost nothing; minutes and storage are not counted. Private repositories draw on the plan’s free allowance: GitHub Free gives 2,000 minutes per month and 500MB of artifact storage, plus 10GB of Actions cache per repository. This pipeline consumes mostly cache — the image layers stored by cache-to: type=gha basically all live within that 10GB. 2,000 minutes is plenty for a personal project: a build takes a few minutes, and running it a hundred times a month is no problem. If you do go over, the extra is billed at $0.006 per minute for a two-core Linux runner, which is not exactly painful.
On the Alibaba Cloud ACR side, the personal edition is free. The official wording is free use within public-beta quotas — neither pulling nor pushing images is charged; the price is no SLA commitment, and there’s no compensation even if the service is unavailable. In terms of specs there are three hard limits: 3 namespaces, 300 public repositories, and 1 concurrent build. A personal project usually puts one application’s images in one namespace, so this quota goes unused. Only when you want enterprise-grade SLA, multi-arch builds, or image security scanning do you need to switch to the paid enterprise edition.
The Actions Side
No need to hand-write docker build; leave it to the official buildx action. Here is the one I use — tweak it and it runs.
name: Build and Push Docker Image to Aliyun ACR
on:
push:
branches:
- master
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
# QEMU lets the x86 runner build ARM64 images too
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Generate the version tag in Beijing time to avoid being a day off in UTC
- name: Get Current Date Tag
id: get-date
run: |
echo "tag=$(TZ=Asia/Shanghai date +'%Y%m%d-%H%M%S')" >> $GITHUB_OUTPUT
echo "date_only=$(TZ=Asia/Shanghai date +'%Y%m%d')" >> $GITHUB_OUTPUT
- name: Log in to Aliyun Container Registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.ALIYUN_REGISTRY }}
username: ${{ secrets.ALIYUN_USERNAME }}
password: ${{ secrets.ALIYUN_PASSWORD }}
- name: Build and Push to ACR
uses: docker/build-push-action@v6
with:
context: ./app
file: ./app/deployments/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_NAMESPACE }}/${{ secrets.ALIYUN_REPOSITORY }}:${{ steps.get-date.outputs.tag }}
${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_NAMESPACE }}/${{ secrets.ALIYUN_REPOSITORY }}:${{ steps.get-date.outputs.date_only }}
${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_NAMESPACE }}/${{ secrets.ALIYUN_REPOSITORY }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
# Aliyun ACR won't accept OCI provenance/sbom manifests; the push fails unless these are off
provenance: false
sbom: false
The three tags each serve a purpose. The minute-and-second timestamp is fixed, which is what you rely on for rollback and troubleshooting; the date one is convenient for manually browsing history; latest is movable, overwritten every time, and that is what the server’s auto-pull watches. Don’t push only the timestamp — a fixed tag’s digest never changes, so the server will never update.
Those two lines, provenance: false and sbom: false, were learned the hard way. By default buildx attaches a provenance attestation alongside the image, pushing an extra manifest that ACR cannot accept — the build succeeds and the push errors out, which is quite confusing the first time.
The Server Side
I recommend letting the server pull on its own. Just install watchtower and let it watch the container.
services:
app:
image: registry.cn-hangzhou.aliyuncs.com/ns/app:latest
watchtower:
image: containrrr/watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
command: --interval 300 app
Every so often it checks the registry, and when it sees the digest of latest has changed, it pulls the new image and recreates the container. In this whole pipeline the server needs no ports exposed to the outside, and Actions does not need to hold the server’s SSH key; the two sides only interact through ACR, and neither needs to know the other.
To roll back, change latest in the compose file to some timestamp tag and run docker compose up -d once to go back. That timestamp was stamped at build time, the image is still in ACR, and the rollback is guaranteed to work.
Another variant has Actions connect to the server and pull, without keeping a long-running process.
- uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USER }}
key: ${{ secrets.SSH_KEY }}
script: cd /opt/app && docker compose pull app && docker compose up -d app
You save a daemon, at the cost of every deployment having to wait until the server is reachable and storing one more private key in Actions. If the server IP changed or the sshd port was modified, you have to sync that here. For a project with just one machine and infrequent deployments, this version is good enough; I tend to treat it as a fallback alongside watchtower.
scp a jar
If there’s no Docker, or it’s just a monolith jar, scp’ing it up and restarting systemd is simpler. Just change the build artifact to a jar file.
- uses: appleboy/scp-action@v1
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USER }}
key: ${{ secrets.SSH_KEY }}
source: target/app.jar
target: /opt/app
- uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USER }}
key: ${{ secrets.SSH_KEY }}
script: systemctl restart app
Fewer stages, and when something breaks you can see it on the spot. The cost is that the JVM, dependencies, and startup parameters all stay on the server; the environment starts to drift, and moving to another machine means re-aligning everything from scratch. If the project has only one server and will never scale out, this path works too; but once there are two environments, the peace of mind of the image approach shows itself.
Where the Most Errors Come From
The errors I hit most are dependency build failures; login failures and network timeouts are actually rare. A package-lock generated on the dev machine, fed into a build image with a different OS version to run npm ci, has binary-bearing optional dependencies that don’t match the platform and fails outright. Same story on the Java side: classes compiled locally with 21, running in an image with 17, throw a version mismatch at startup.
This has nothing to do with which pipeline you use. Docker just makes the problem surface earlier; the versions you need to match locally have to match there too — the local machine merely happened to have the right ones installed. The fix is to use the same base image for the build image and the runtime image, which a multi-stage build satisfies naturally.
FROM node:20-bookworm AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-bookworm AS run
WORKDIR /app
COPY --from=build /app/dist ./dist
CMD ["node", "dist/main.js"]
Writing 20 instead of 20.11.1 for node is to keep getting security patches; but the build and run lines must be identical, and that cannot be relaxed.
The architecture question is already handled on the build side. platforms: linux/amd64,linux/arm64 plus QEMU produces a multi-arch manifest when pushed; an x86 server pulls x86, an ARM server pulls ARM, chosen automatically. The cost is that QEMU-emulated ARM builds are slower, though the type=gha cache recovers most of it. Don’t hard-code --platform in the Dockerfile, as that presses the multi-arch layer back down to single-arch.
Why the Extra Layer Is Worth It
In the scp pipeline, only that one machine knows what’s installed and what’s missing on the server. Once you switch to images, the build result is frozen into something that can run on any machine, and the server degrades into a box that only knows how to pull and up -d.
Coming back to a personal project half a year later, the easiest thing to forget is what was ever installed on the server. The image registry keeps that memory in your own hands, and conveniently turns rollback into a single tag switch. That is why I recommend it; automation is just a side benefit.
Comments