Community
0
HostiServer
2026-09-30 11:19

Docker + CI/CD: Automatic Deployment

⏱️ Reading time: ~12 minutes | 📅 Updated: September 2026

Docker + CI/CD: automatic deployment

The stack from the previous article is already production-ready: Nginx, SSL, autostart after a reboot. But deploying a new version still looks the same as it did back in the very first part of the series — change the code, run git push, SSH into the server, and manually run docker compose build && docker compose up -d. That's monotonous, error-prone, and stops scaling the moment more than one person is deploying.

A CI/CD pipeline removes exactly this manual step. Every push to the main branch automatically triggers a sequence of actions: building the image, publishing it to a registry, and updating the service on the server — with no manual SSH login.

This article is deliberately not tied to any specific CI platform. We've already covered the concept of CI/CD, the difference between Continuous Integration, Delivery, and Deployment, and pipeline structure (trigger, stage, job, artifact) in a dedicated series — the place to start is "What CI/CD Is: From Manual Deploy to Automated Pipelines". docker compose from article 2 and Nginx with SSL from article 3 of this series are also assumed knowledge here. What's left is only the part specific to Docker itself: how an image gets from CI onto the server, and exactly what changes in docker-compose.yml.

The result by the end of this article: git push to main — and within two or three minutes, the new version is already running on the server.

2. Docker image registry

2.1 Why a registry is needed

CI builds the image in its own ephemeral environment — and that image disappears the moment the job finishes. The server needs to fetch it from somewhere. A registry is the middle link: CI publishes the image there right after the build, and the server later pulls it from there with the pull command.

2.2 Registry options

Registry When it fits
Docker Hub Free, with a rate limit on anonymous pulls; enough for personal and small projects
GitHub Container Registry (ghcr.io) Free for public repositories, convenient if CI already runs on GitHub Actions
GitLab Registry / Gitea Registry Built directly into the platform — convenient for self-hosted setups with no extra service
Self-hosted (registry:2) Full control, no external limits; needs a separate server or resources on the same VPS

2.3 Naming images and tags

The typical format is registry/namespace/app:tag. The tag is usually either a short git SHA (git rev-parse --short HEAD) or latest. A SHA gives you reproducibility — you always know exactly which commit is running on the server; latest is convenient for quickly accessing the newest version. In practice, CI pushes both tags at once, onto the same image.

2.4 A private registry and authorization

If the registry is private, both CI and the server need to authenticate before working with it — docker login with credentials. These credentials are passed only through the CI platform's secrets, never written directly into the pipeline file or the code.

3. Changes to docker-compose.yml

3.1 How it looked before

Before auto-deploy, the app service had build: . — Compose itself built the image right on the server from the local Dockerfile on every run.

3.2 How it changes with CI/CD

build: . gets replaced with image: registry/namespace/app:latest. Compose on the server no longer builds anything — it just pulls the ready-made image that CI built and published.

Migrating docker-compose.yml from build: to image: — CI builds and publishes the image, the server only pulls the finished result

3.3 Two compose files

A practical setup is to keep two files: docker-compose.yml for production with image:, and docker-compose.override.yml (or docker-compose.dev.yml) for local development with build:. Both live in git — there's nothing extra to exclude in .gitignore, and the pipeline on the server explicitly specifies using the production file.

3.4 The tag as a variable

Instead of hardcoding latest, it's more convenient to set the tag as a variable: image: registry/namespace/app:${APP_VERSION:-latest}. This lets you deploy a specific version just by passing a value for APP_VERSION through .env or directly from the pipeline, with no need to edit the compose file itself.

4. The pipeline: steps and logic

4.1 The trigger

Deployment runs on a push to the main branch (or a dedicated production one). Pull requests and feature branches get built and tested as described in the CI/CD series, but aren't deployed — this matches the difference between Continuous Integration and Continuous Deployment.

4.2 The overall sequence

  1. Check out the code
  2. Authenticate with the registry (docker login)
  3. Build the image: docker build -t registry/namespace/app:sha .
  4. Publish the image to the registry (both the sha tag and the latest tag)
  5. Connect to the server over SSH
  6. docker compose pull app — pulls the new image
  7. docker compose up -d --no-deps app — restarts only app

A full CI/CD pipeline for Docker: from code checkout to SSH deploy on the server

4.3 Why --no-deps is critically important

Without the --no-deps flag, up -d restarts not just app, but every service it depends on through depends_on — meaning both db and cache. That's a brief, but entirely unnecessary, moment of database unavailability during what should be a routine app update. A rolling deploy of a new version only needs to restart app itself.

4.4 SSH access from CI

A dedicated SSH key pair gets set up for deployment, rather than any individual team member's personal key. The public key gets added to ~/.ssh/authorized_keys on the server, while the private key lives exclusively in the CI platform's secrets.

4.5 An alternative to SSH

Instead of a direct SSH connection, you can stand up a small HTTP service on the server itself — a webhook that accepts a POST request from CI and internally runs pull and up -d. This approach gives the CI platform fewer permissions over the server (no direct shell access), but requires separately setting up and maintaining the webhook service itself.

5. Secrets and security

5.1 What to store in CI secrets

The minimum set: REGISTRY_USER, REGISTRY_PASSWORD, SSH_PRIVATE_KEY, SERVER_HOST, SERVER_USER.

5.2 What must never be in the code

None of these values should ever appear in the code itself, in the Dockerfile, in docker-compose.yml, or in the git history — not even in .env files inside the repository. The repository should only contain an .env.example with empty or placeholder values, as a reference for which variables are even needed.

5.3 Restricting the deploy SSH key

It's worth making the deploy key separate from every other key and, where possible, restricting what it can run via a command= directive in authorized_keys on the server — that way, even if the key is compromised, it can't be used to run arbitrary commands. If a compromise does happen, a dedicated key like this can be revoked without affecting anyone else's or any other service's access.

5.4 Registry credentials on the server

docker login on the server is run once manually, and the credentials get stored locally in ~/.docker/config.json. An automatic re-login as part of every deploy is only needed if the credentials get rotated regularly.

6. Versioning and rollback

6.1 Why latest alone isn't enough

If deploying a new version breaks something, the latest tag has already been overwritten — you can no longer rely on it to roll back. You need a different, immutable identifier for the specific version.

6.2 The SHA tag as a safety net

Every push stores an image in the registry tagged with something that never gets reused — the short git SHA of that commit. A rollback then means passing the previous SHA back through the pipeline, or running it manually:

docker compose pull
APP_VERSION=abc1234 docker compose up -d

6.3 Keeping old images

A registry doesn't free up space on its own — it's worth setting up a policy to keep the last N images (through the registry's built-in retention policies, or a separate cron cleanup script). This matters especially where registry storage is billed — an unbounded history of tags gradually turns into paid dead weight.

6.4 Rollback baked into the pipeline itself

The most convenient setup is a separate CI job, triggered manually with a SHA or version parameter, that runs the same deploy steps but with the old tag. Without a job like this, rollback goes right back to a manual SSH login — exactly what auto-deploy was supposed to eliminate.

7. Zero-downtime deployment

7.1 A realistic level for most projects

docker compose up -d --no-deps app has a brief gap, typically 1-2 seconds, between stopping the old container and starting the new one. For the vast majority of sites and internal services, that's an acceptable level — at worst, a user sees one slow response instead of a noticeable outage.

7.2 True zero-downtime

Eliminating that gap entirely requires a blue/green setup: two parallel containers (app_blue and app_green), with Nginx switching the upstream to the new one only after it's confirmed ready. This is noticeably more complex infrastructure, and it's justified where an SLA demands more than 99.9% availability — for a typical self-hosted project on a single VPS, it's generally overkill.

7.3 A safety net for a failed startup

restart: unless-stopped together with a healthcheck on the app service is a minimal but effective safety net: if the new container fails to come up (a crash on startup, an OOM kill from an insufficient memory limit), Docker restarts it on its own, and Nginx returns a 502 only for the brief restart window rather than indefinitely.

8. A practical pipeline example (pseudocode)

8.1 The abstract shape

Regardless of the specific platform, the pipeline logic looks the same:

on: push to main
steps:
  - checkout
  - login to registry
  - build image
  - push image
  - ssh deploy

ℹ️ A platform-specific implementation: a step-by-step example of exactly this pipeline on GitHub Actions, GitLab CI, and Gitea Actions is covered in the "CI/CD from Scratch" series, which also explains how the platforms' syntax differs in detail.

8.2 Pipeline environment variables

The image name and tag are usually pulled out into variables: IMAGE_NAME, IMAGE_TAG. The commit SHA itself is exposed by each platform under its own name — $GITHUB_SHA on GitHub Actions, $CI_COMMIT_SHORT_SHA on GitLab CI, its own equivalent on Gitea Actions. The variable names differ; the concept is the same.

8.3 The deploy command itself

The last step is SSHing into the server and running the update command. This can be written either as one compound command right in the pipeline file, or moved into a separate deploy.sh script in the repository. The second option is more convenient: the script can be tested locally before it ever reaches the production pipeline, and its changes show up in git history exactly like code changes do.

9. Conclusion

Now every git push to main means an updated version on the server within two or three minutes, with no manual SSH login and no risk of forgetting a step.

The full picture looks like this: code in git → CI builds the image and publishes it to a registry → the server pulls the ready-made image and restarts only the app service → Nginx keeps serving traffic exactly as before, not even noticing that an update just happened behind it.

The details of setting up the pipeline itself for a specific platform — GitHub Actions, GitLab CI, or Gitea Actions — are outside the scope of this article: they're covered in depth in the separate "CI/CD from Scratch" series. Here we've covered only the Docker-specific piece of the puzzle: the registry, changes to the compose file, SSH deployment, versioning, and rollback.

The full cycle — from code to a running container on the server — is now assembled. The last step of the series is bringing Git itself onto that same server: the next article closes out the series with Gitea or Forgejo, running in Docker alongside a CI runner, as a fully self-hosted stack.

📚 Series navigation:
You're reading part 4 of 5, "Docker + CI/CD: Automatic Deployment."
Previous: Part 3. Docker in Production ←
Next: Part 5. Gitea / Forgejo in Docker: A Full Self-Hosted Stack →

🚀 A server ready to accept CI deploys every day

Auto-deploy means regular pulls and container restarts — and the server needs to handle that with no impact on whoever's using the app at that exact moment. Hostiserver gives you predictable resources for exactly this.

🖥️ Dedicated Servers

  • From $90/mo, full control over the hardware for frequent production deploys
  • No resource neighbors: that brief container-restart moment doesn't depend on someone else's load
  • 24/7 support: we'll help with SSH access for CI and setting up a self-hosted registry

💻 Cloud (VPS) Hosting

  • From $19.95/mo, KVM isolation, dedicated vCPU and RAM
  • NVMe disks: fast pulls of new images with no disk-related delays
  • A stable network: SSH deploys from CI aren't at the mercy of a flaky connection
  • Easy to scale: more resources as deploys get more frequent

💬 Not sure which option you need?
💬 Reach out — we'll help you figure it out!

Frequently Asked Questions

Docker Hub or a self-hosted registry — which should a small project choose?

For one or two private projects, Docker Hub or the built-in GitHub/GitLab/Gitea registry (depending on where the code already lives) is the simplest starting point, with no extra infrastructure. A self-hosted registry:2 makes sense once free-tier limits stop being enough, or there's a requirement to keep everything inside your own perimeter.

Why can't I just deploy off the latest tag alone?

The latest tag gets overwritten with every new push, so it doesn't work as a rollback point — the previous state simply disappears. A parallel tag based on the git SHA stays immutable forever, and that's exactly what lets you return to a specific version instead of only being able to move forward.

Why is --no-deps absolutely necessary when updating app?

Without this flag, docker compose up -d restarts every service linked through depends_on, including the database and cache — even if only the app's code changed. --no-deps limits the restart to just the service that actually got updated.

How do I safely store an SSH key for CI?

A dedicated key used only for deployment and nowhere else, with the private half living exclusively in the CI platform's secrets (never in the repository). On the server, it's worth restricting exactly what that key can run via a command= directive in authorized_keys — that way, a compromised key doesn't hand an attacker an arbitrary shell on the server.

SSH deploy or a webhook — which is simpler to start with?

SSH is simpler to set up right away: a key pair and a few pipeline commands. A webhook requires standing up and maintaining a small separate service on the server, but in exchange gives CI fewer permissions — no direct shell access. For a single project on a single VPS, SSH is usually enough.

The new container crashed right after deploy — does the pipeline still count as successful?

Technically, yes — docker compose up -d on its own just starts the container and doesn't wait for the app inside it to actually become ready. That's why it's worth adding a separate post-deploy check to the pipeline: a short curl against the app's health endpoint with a small delay, rather than relying solely on the command's exit code.

Does a small project need zero-downtime (blue/green) deployment?

Mostly no. The standard --no-deps restart gives a one-to-two-second gap, which is acceptable for most sites and internal services. Blue/green is justified when there's a formal SLA requirement above 99.9% availability, or when even a brief 502 is unacceptable — for a high-traffic payment gateway, say.

Contents

Share this article

MANAGED VPS STARTING AT

$19 95 / mo

NEW INTEL XEON BASED SERVERS

$80 / mo

CDN STARTING AT

$0 / mo

 

By using this website you consent to the use of cookies in accordance with our privacy and cookie policy.