News
Public API for VMware is now available in Serverspace
Serverspace Black Friday
DF
Daniil Fedorov
August 30 2026
Updated August 30 2026

How to Set Up CI/CD: From GitHub Repository to Automated Server Deploymen

How to Set Up CI/CD: From GitHub Repository to Automated Server Deploymen

Manual deployment works well during the early stages of development: a developer connects to the server, uploads a new version of the project, installs dependencies, and restarts the application. As the project grows, however, this process quickly becomes a source of errors.

You might forget to run a database migration, install the wrong dependency version, deploy code from the wrong branch, or simply skip one of the required steps. On top of that, every release starts consuming more developer time.

CI/CD solves this problem by automating the process of testing, building, and delivering changes to a server with minimal manual intervention.

In a simple setup, a developer only needs to push changes to the main branch:

git push origin main

The CI/CD system can then automatically:

  1. retrieve the source code;
  2. install dependencies;
  3. run tests;
  4. build a Docker image;
  5. publish it to a container registry;
  6. connect to the server;
  7. download the new application version;
  8. restart the container.

In this guide, we will build this pipeline from scratch. We will use GitHub and GitHub Actions for source control and CI/CD, package the application with Docker, store container images in Docker Hub, and deploy the production environment to an Ubuntu VPS.

The final architecture will look approximately like this:

GitHub Repository

Project source code
Push / Pull Request

GitHub Actions

CI/CD Pipeline
Install Dependencies
Run Tests
Docker Build

Docker Registry

Ready-to-deploy application image
SSH Deploy

Ubuntu VPS

Docker Compose + Application

Production

New version available to users
After changes are pushed to main, the entire path from testing to production deployment runs automatically

Deploy a Server for CI/CD with Serverspace

An automated pipeline ends where your production infrastructure begins. To build a complete CI/CD workflow, you need a server that remains available online and can receive new application versions automatically.

With Serverspace, you can deploy a cloud VPS and use it as a production environment for a website, API, backend service, or another application.

Ubuntu is a practical starting point for CI/CD infrastructure. You can select the amount of CPU, RAM, and SSD storage required by your workload and scale the configuration as the project grows.

A VPS can host:

  • Docker containers;
  • backend applications;
  • REST APIs;
  • Node.js, Python, PHP, and other runtimes;
  • Nginx;
  • PostgreSQL and other databases;
  • staging and production environments;
  • self-hosted CI/CD tools.

For the project in this guide, an Ubuntu VPS with a public IP address and SSH access is enough.

Deploy a VPS in Serverspace, choose Ubuntu, and use it as the destination for your automated deployment pipeline.

What Is CI/CD?

CI/CD combines several development automation practices.

CI stands for Continuous Integration.

Its purpose is to automatically validate code changes before they reach production.

After every push or pull request, the CI system can:

  • install dependencies;
  • run a linter;
  • execute unit tests;
  • execute integration tests;
  • build the application;
  • verify that the Docker image can be built successfully.

CD can mean either Continuous Delivery or Continuous Deployment.

With Continuous Delivery, a release-ready version is automatically tested and prepared, but deployment to production still requires manual approval.

With Continuous Deployment, every change that successfully passes the pipeline is automatically deployed to production without an additional manual step.

The difference can be summarized as follows:

Approach What Is Automated Production Deployment
Continuous Integration Builds, checks, and testing Not necessarily included
Continuous Delivery Preparing a release-ready build After approval
Continuous Deployment The full path from commit to production Automatic

In this guide, we will implement the third approach: after a successful merge or push to main, the new version will automatically be deployed to the server.

CI/CD Pipeline Components

Our example uses five main components.

GitHub

The Git repository acts as the source of the application code and triggers the pipeline when changes are pushed.

GitHub Actions

GitHub Actions runs CI/CD jobs on dedicated runners.

This is where tests, Docker image builds, and deployment steps will execute.

Docker

Docker packages the application, runtime, and dependencies into a portable container image.

Instead of manually installing each new version on the server, the CI/CD pipeline can simply replace the old container with a new one.

Docker Hub

The container registry stores built Docker images.

GitHub Actions publishes the new image to Docker Hub, while the production server downloads the required version.

If necessary, you can replace Docker Hub with GitHub Container Registry, GitLab Container Registry, or your own private registry.

Ubuntu VPS

The server runs Docker Compose, which starts the required application version.

Prerequisites

To follow this guide, you will need:

  • a GitHub repository containing your project;
  • a Docker Hub account;
  • an Ubuntu VPS;
  • SSH access to the server;
  • a Dockerfile for the application;
  • a command that runs your project tests.

We will use a Node.js project for demonstration purposes.

However, the overall architecture is largely independent of the programming language.

For example, the CI stage can use different commands depending on the stack:

Stack Install Dependencies Run Tests
Node.js npm ci npm test
Python pip install -r requirements.txt pytest
PHP composer install phpunit
Go go mod download go test ./...

Once the tests are complete, the remaining stages — Docker build, registry publication, and deployment — can stay almost identical.

Step 1. Prepare the Git Repository

If the project is not yet tracked by Git, initialize the repository:

git init

Add the files:

git add .
git commit -m "Initial commit"

After creating the remote repository on GitHub, connect it:

git remote add origin [git@github.com](mailto:git@github.com):USERNAME/PROJECT.git
git branch -M main
git push -u origin main

Replace USERNAME and PROJECT with your GitHub account and repository names.

For CI/CD, production deployment should only be triggered from a controlled branch.

A common branch structure is:

  • main — production;
  • develop — integration branch;
  • feature branches — individual development tasks.

For a small project, main plus feature branches is often enough.

The workflow can look like this:

feature/login

Feature branch with new changes

Pull Request

Review changes before merge

CI: tests

Run automated tests

main

Main production branch

CI: tests + build

Run tests again and build the application

CD: production deploy

Automatic deployment to production
A feature branch first passes CI through a pull request. After it is merged into main, the pipeline runs another test and build cycle before deploying to production

This way, a regular pull request triggers validation but does not modify production.

Step 2. Add a Dockerfile

Create a file named:

Dockerfile

in the root of the repository.

For a simple Node.js application, it can look like this:

FROM node:22-alpine

WORKDIR /app

COPY package*.json ./

RUN npm ci --omit=dev

COPY . .

ENV NODE_ENV=production

EXPOSE 3000

CMD ["npm", "start"]

Here:

  • FROM defines the base image;
  • WORKDIR creates the working directory;
  • COPY adds project files to the image;
  • npm ci installs dependencies;
  • EXPOSE documents the application port;
  • CMD starts the application.

The exact Dockerfile will depend on your application stack.

For production workloads, you can also use multi-stage builds to separate the compilation environment from the final runtime image.

Step 3. Create a .dockerignore File

You generally should not copy the entire development directory into the Docker image.

Create:

.dockerignore

For example:

node_modules
npm-debug.log
.git
.github
.env
.env.*
coverage
README.md

It is especially important to exclude .env and other files containing secrets.

Passwords, API keys, and production configuration should never be baked into the Docker image.

Step 4. Test the Docker Image Locally

Before adding CI/CD automation, make sure the project can be built manually.

Run:

docker build -t myapp:test .

Start the container:

docker run --rm -p 3000:3000 myapp:test

The application should now be available at:

[http://localhost:3000

(http://localhost:3000[/code)]

If the local Docker build does not work, CI/CD automation will not fix it.

Your pipeline should automate an already working build and deployment process, not replace one.

Step 5. Prepare the Ubuntu Server

Connect to the VPS:

ssh root@SERVER_IP

Update the system:

apt update
apt upgrade -y

Install the required packages:

apt install -y ca-certificates curl

Create a directory for APT keys:

install -m 0755 -d /etc/apt/keyrings

Add the key for Docker's official repository:

curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc

chmod a+r /etc/apt/keyrings/docker.asc

Add the Docker repository:

cat > /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF

Refresh the package index:

apt update

Install Docker Engine and Docker Compose:

apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Check the Docker service:

systemctl status docker

Check the Docker Compose version:

docker compose version

Step 6. Create a Dedicated Deployment User

It is not recommended to give your CI/CD pipeline permanent SSH access through the root account.

Create a separate user:

adduser deploy

Add it to the Docker group:

usermod -aG docker deploy

Create the application directory:

mkdir -p /opt/myapp
chown -R deploy:deploy /opt/myapp

Now exit the root session and reconnect as the new user:

ssh deploy@SERVER_IP

Check Docker access:

docker ps

If the command works without sudo, the user is ready for deployment.

Step 7. Configure an SSH Key for GitHub Actions

GitHub Actions must be able to connect to the server without entering a password interactively.

On your local machine, create a dedicated SSH key pair:

ssh-keygen -t ed25519 -C "github-actions-deploy" -f github_actions_deploy

This creates two files:

github_actions_deploy
github_actions_deploy.pub

The first file contains the private key.

The second contains the public key.

Add the public key to the server:

ssh-copy-id -i github_actions_deploy.pub deploy@SERVER_IP

Alternatively, add its contents manually to:

/home/deploy/.ssh/authorized_keys

Test the connection:

ssh -i github_actions_deploy deploy@SERVER_IP

We will add the private key to GitHub Secrets later.

Do not commit it to your Git repository.

Create a Production Environment in Serverspace for Automated Deployment

Once the pipeline is configured, the server becomes a permanent part of your development workflow.

Instead of manually uploading every release, the CI/CD system connects to the VPS and updates the containers automatically.

With Serverspace, you can start with a small virtual machine and increase CPU, RAM, or storage as application requirements grow.

A production environment can also be divided into several components:

GitHub Actions

Serverspace VPS

Production infrastructure
↙ ↓ ↘
Application
Database
Monitoring
As the project grows, individual components can be moved to separate virtual machines and scaled independently

This allows you to keep the same CI/CD pipeline even when moving from a single VPS to a more complex infrastructure.

Step 8. Create the Docker Compose Configuration

Go to the application directory:

cd /opt/myapp

Create:

compose.yaml

Add:

services:
app:
image: yourdockerhubuser/myapp:${APP_TAG:-latest}
container_name: myapp
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
env_file:
- app.env

Replace:

yourdockerhubuser/myapp

with the name of your Docker Hub repository.

Pay attention to:

${APP_TAG:-latest}

This variable allows the server to run a specific Docker image associated with a particular commit instead of always using only the latest version.

We will use this capability for rollback.

Step 9. Add Application Environment Variables

Production secrets are better stored on the server rather than inside the Docker image.

Create:

nano /opt/myapp/app.env

For example:

NODE_ENV=production
DATABASE_URL=postgresql://user:password@database:5432/app
API_KEY=change_me

Restrict file permissions:

chmod 600 /opt/myapp/app.env

The app.env file does not need to be stored in GitHub.

This allows you to build the same Docker image and use different configuration values in staging and production.

Step 10. Configure Docker Hub

Create a repository in Docker Hub.

For example:

yourdockerhubuser/myapp

For CI/CD, use an access token instead of your primary account password.

If the repository is private, authenticate the production server once:

docker login

After successful authentication, the server will be able to run:

docker pull yourdockerhubuser/myapp:latest

If the repository is public, separate authentication is generally not required for pulling the image.

Step 11. Add GitHub Secrets

Open the GitHub repository and go to:

Settings
→ Secrets and variables
→ Actions

Create the following repository secrets:

Secret Value
DOCKERHUB_USERNAME Docker Hub username
DOCKERHUB_TOKEN Docker Hub access token
SERVER_HOST Production server IP address or domain
SERVER_USER For example, deploy
SERVER_SSH_KEY Contents of the private github_actions_deploy key
SERVER_HOST_KEY SSH host key of the server

You can retrieve the host key with:

ssh-keyscan -H SERVER_IP

Add the resulting line to SERVER_HOST_KEY.

For a production environment, verify the server fingerprint through a trusted channel before storing the host key. Avoid blindly accepting any SSH host key during deployment.

Step 12. Create the GitHub Actions Workflow

Create the following directory in your repository:

.github/workflows

Inside it, create:

deploy.yml

Add the following workflow:

name: CI/CD

on:
pull_request:
branches:
- main

push:
branches:
- main

jobs:
test:
name: Test application
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v6

- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: "22"
cache: "npm"

- name: Install dependencies
run: npm ci

- name: Run tests
run: npm test

build:
name: Build and push Docker image
runs-on: ubuntu-latest
needs: test

if: github.event_name == 'push' && github.ref == 'refs/heads/main'

env:
IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/myapp

steps:
- name: Checkout repository
uses: actions/checkout@v6

- name: Login to Docker Hub
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
run: |
echo "$DOCKERHUB_TOKEN" | docker login \
-u "$DOCKERHUB_USERNAME" \
--password-stdin

- name: Build Docker image
run: |
docker build \
-t "$IMAGE_NAME:$GITHUB_SHA" \
-t "$IMAGE_NAME:latest" \

- name: Push Docker image
run: |
docker push "$IMAGE_NAME:$GITHUB_SHA"
docker push "$IMAGE_NAME:latest"

deploy:
name: Deploy to production
runs-on: ubuntu-latest
needs: build

concurrency:
group: production
cancel-in-progress: false

env:
SERVER_HOST: ${{ secrets.SERVER_HOST }}
SERVER_USER: ${{ secrets.SERVER_USER }}
SERVER_SSH_KEY: ${{ secrets.SERVER_SSH_KEY }}
SERVER_HOST_KEY: ${{ secrets.SERVER_HOST_KEY }}

steps:
- name: Configure SSH
run: |
mkdir -p ~/.ssh

printf '%s\n' "$SERVER_SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519

printf '%s\n' "$SERVER_HOST_KEY" > ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts

- name: Deploy application
run: |
ssh "$SERVER_USER@$SERVER_HOST"
cd /opt/myapp &&
printf 'APP_TAG=%s\n' '$GITHUB_SHA' > .deploy.env &&
docker compose --env-file .deploy.env pull &&
docker compose --env-file .deploy.env up -d --remove-orphans

This is already a complete CI/CD pipeline.

Now let's break down how it works.

How the on Section Works

The workflow starts on two events:

on:
pull_request:
branches:
- main

push:
branches:
- main

A pull request targeting main starts CI.

A push to main starts CI and, if the tests succeed, continues with the build and deployment jobs.

This separation is important.

Simply opening a pull request should not automatically change the production environment.

How the test Job Works

The first job is:

test

It runs for both pull requests and pushes.

GitHub creates a temporary runner:

runs-on: ubuntu-latest

It then checks out the repository:

- uses: actions/checkout@v6

Sets up Node.js:

- uses: actions/setup-node@v7
with:
node-version: "22"
cache: "npm"

Installs dependencies:

npm ci

And runs the test suite:

npm test

If any command exits with a non-zero status code, the job fails.

The following stages will not start.

This is the point where CI prevents an obviously broken version from reaching production.

How the Docker Build Works

The next job depends on the test job:

needs: test

It also uses the following condition:

if: github.event_name == 'push' && github.ref == 'refs/heads/main'

As a result, the Docker image is published only after an actual change reaches main.

The pipeline authenticates with Docker Hub:

echo "$DOCKERHUB_TOKEN" | docker login
-u "$DOCKERHUB_USERNAME"
--password-stdin

It then creates two tags:

docker build
-t "$IMAGE_NAME:$GITHUB_SHA"
-t "$IMAGE_NAME:latest"

For example, the registry might contain:

myapp:latest
myapp:ac34a0f7...
myapp:c014d9b2...
myapp:8bfc2201...

The latest tag provides a convenient pointer to the newest build.

SHA tags make it possible to associate every image with a specific Git commit.

We will use SHA tags for production deployment.

Why Deploy by Commit SHA Instead of latest?

Consider the following example.

On Monday, you deploy commit:

1a2b3c

On Tuesday, you deploy:

4d5e6f

After the second deployment, a problem appears.

If the server only uses:

myapp:latest

it may not be immediately obvious which exact image version was running before the update.

With SHA tags:

myapp:1a2b3c
myapp:4d5e6f

each version can be identified unambiguously.

That is why the workflow writes the current commit SHA to:

/opt/myapp/.deploy.env

For example:

APP_TAG=4d5e6f...

Docker Compose uses this value here:

image: yourdockerhubuser/myapp:${APP_TAG:-latest}

This keeps the production environment tied to a specific Git commit.

How SSH Deployment Works

The final job only runs after a successful build:

needs: build

GitHub Actions prepares the SSH configuration and runs:

ssh "$SERVER_USER@$SERVER_HOST"

On the remote server, the pipeline moves to:

/opt/myapp

It records the image tag:

APP_TAG=$GITHUB_SHA

Then runs:

docker compose --env-file .deploy.env pull

Docker downloads the new image.

After that:

docker compose --env-file .deploy.env up -d --remove-orphans

Docker Compose recreates the application container using the new version.

The developer no longer needs to manually connect to production for every release.

The Complete Path of a Code Change

Now let's look at the full workflow.

The developer creates a branch:

git checkout -b feature/new-api

Makes changes and pushes them:

git add .
git commit -m "Add new API endpoint"
git push origin feature/new-api

After a pull request is opened:

Pull Request
npm ci
npm test

If all checks succeed, the pull request can be merged into main.

After the merge:

main

Production branch

Tests

Automated code validation

Docker Build

Build the Docker image

Docker Hub

Publish the ready image

SSH

Connect to the production server

VPS

Production infrastructure

docker compose pull

Download the new Docker image

docker compose up

Update and start containers

Production

New application version available to users
After main changes, the pipeline tests the code, builds and publishes the Docker image, connects to the VPS, and automatically updates production containers

Everything after the merge happens automatically.

Step 13. Verify the First Deployment

After merging, open:

GitHub
→ Actions
→ CI/CD

You should see three jobs:

Test application
Build and push Docker image
Deploy to production

They should complete successfully in sequence.

Then connect to the server:

ssh deploy@SERVER_IP

Check the running container:

docker ps

The output should look similar to:

CONTAINER ID IMAGE STATUS
5ae62b43d810 yourdockerhubuser/myapp:abc123 Up 1 minute

Check the saved deployment tag:

cat /opt/myapp/.deploy.env

For example:

APP_TAG=abc123...

To view application logs:

cd /opt/myapp
docker compose --env-file .deploy.env logs -f

How to Add Nginx

A production application is usually not exposed directly to the internet through the Node.js application port.

Our Compose configuration uses:

127.0.0.1:3000:3000

This means port 3000 is only accessible locally on the server.

You can place Nginx in front of the application.

Install it:

sudo apt install nginx

Create a configuration:

sudo nano /etc/nginx/sites-available/myapp

Example:

server {
listen 80;
server_name example.com [www.example.com](http://www.example.com);

location / {
proxy_pass http://127.0.0.1:3000;

proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}

}

Enable it:

sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/myapp

Test the configuration:

sudo nginx -t

Reload Nginx:

sudo systemctl reload nginx

The production architecture now looks like this:

Internet

Incoming user traffic

Nginx :80 / :443

HTTP/HTTPS and reverse proxy

127.0.0.1:3000

Local application port

Docker Container

Isolated runtime environment

Application

Backend or web application
Nginx receives external HTTP/HTTPS traffic and proxies requests to the local port of the Docker container running the application

For a production website, the next step is usually to configure HTTPS.

How to Roll Back a Deployment

Automating deployment does not eliminate the possibility of a bad release.

For example:

  • your tests may not cover a specific scenario;
  • an external API may have changed;
  • an error may only appear with production data;
  • a performance regression may occur.

Suppose the current version is:

APP_TAG=4d5e6f

and the previous stable version is:

APP_TAG=1a2b3c

To roll back, connect to the server:

ssh deploy@SERVER_IP

Go to the application directory:

cd /opt/myapp

Set the previous image tag:

echo "APP_TAG=1a2b3c" > .deploy.env

Pull the image:

docker compose --env-file .deploy.env pull

Restart the application:

docker compose --env-file .deploy.env up -d --remove-orphans

Production will now run the previous version again.

This is why immutable tags based on commit SHA are more useful for production than relying only on latest.

Add a Health Check After Deployment

Successfully starting a container does not guarantee that the application is actually responding to requests.

You can add an automated endpoint check after deployment.

For example, if the application provides:

https://example.com/health

add the following step after SSH deployment:

- name: Verify deployment
run: |
sleep 10
curl --fail --silent --show-error
https://example.com/health

The endpoint might return:

HTTP/1.1 200 OK

and, for example:

{
"status": "ok"
}

If curl receives an error response, GitHub Actions marks the job as failed.

In a more mature CI/CD pipeline, this step can also trigger an automatic rollback.

Separate Staging and Production

Deploying every change directly to production is not suitable for every project.

A safer workflow looks like this:

Feature Branch

Develop a new feature

Pull Request

Review and merge changes

CI

Automated tests and checks

develop

Integration branch

Staging

Deploy to the test environment

Verification

Final validation of the new version

main

Production branch

Production

Live application version
Changes pass CI and staging validation before moving from develop to main and being deployed to production

You can use two separate servers or two independent environments for this setup.

For example:

Branch Environment Address
develop Staging staging.example.com
main Production example.com

Each environment can use separate SSH credentials and GitHub Secrets.

Add Manual Approval Before Production Deployment

Continuous Deployment is not always the right choice.

For business-critical applications, it can be safer to automate:

  • testing;
  • building;
  • publishing the Docker image;

while still requiring manual approval before production deployment.

In GitHub Actions, you can create a separate Environment:

production

and configure protection rules for it.

Then add the environment to the deployment job:

environment:
name: production

The pipeline will stop before production and continue after the configured approval requirements are satisfied.

This effectively turns Continuous Deployment into Continuous Delivery.

Do Not Store Secrets in the Repository

One of the most serious CI/CD mistakes is storing credentials directly in the workflow YAML.

Do not do this:

env:
SERVER_PASSWORD: "mypassword123"
API_KEY: "secret-api-key"
DOCKER_PASSWORD: "password"

Even a private repository is not an appropriate place for secrets.

Use:

  • GitHub Secrets;
  • Environment Secrets;
  • a dedicated secret manager;
  • a production .env file with restricted permissions;
  • separate CI/CD credentials.

You should also avoid using the same SSH key for both a developer's personal access and automated deployment.

Restrict Deployment User Permissions

The deployment pipeline should only have the permissions it actually requires.

In our example, the deploy user needs:

  • SSH access;
  • access to /opt/myapp;
  • permission to manage Docker.

CI/CD should not receive direct root access unless it is genuinely required.

For stricter environments, you can further restrict:

  • allowed SSH commands;
  • network access;
  • sudo permissions;
  • access to other directories;
  • access to the production database.

Keep in mind that membership in the Docker group effectively gives a user broad control over the host. Infrastructure with stricter security requirements should use an even more restrictive deployment model.

Do Not Deploy When Tests Fail

One of the main benefits of a CI/CD pipeline is the dependency between its stages.

The correct process looks like this:

Tests

Automated application validation
Success
Failed →

Stop

Pipeline stops

Build

Build the application or Docker image
Success
Failed →

Stop

Deployment does not start

Deploy

Deploy the validated build
Each CI/CD stage starts only after the previous stage succeeds. A test or build failure stops the pipeline before deployment

Deployment should never run independently of CI results.

In GitHub Actions, this dependency is implemented with:

needs: test

and:

needs: build

What Else Can You Add to CI?

Our example only runs application tests, but a production CI pipeline can include many more checks.

For example:

Checkout

Retrieve source code

Install Dependencies

Install project dependencies

Lint

Check code quality and style

Unit Tests

Validate individual components

Integration Tests

Validate interaction between components

Security Scan

Scan code and dependencies for vulnerabilities

Docker Build

Build the container image

Container Scan

Scan the Docker image for vulnerabilities

Publish Artifact

Publish the validated build
An extended CI pipeline validates the code, tests the application, checks security, and publishes the artifact only after every stage succeeds

Depending on the project, you might add:

  • ESLint;
  • Prettier;
  • TypeScript compilation;
  • pytest;
  • PHPUnit;
  • SonarQube;
  • SAST;
  • dependency scanning;
  • Docker image scanning;
  • dependency license checks.

The earlier a pipeline detects a problem, the cheaper and easier it is to fix.

How to Handle Database Migrations

Deployment becomes more complicated when a new application version requires changes to the database schema.

You cannot simply replace the container if the new code expects a table or column that does not exist yet.

One possible workflow is:

Build

Build the new application version

Deploy Image

Deliver the new image to the server

Database Migration

Update the database schema

Start Application

Start the new version

Health Check

Verify application availability and health
For deployments with migrations, the new image is delivered first, then the database schema is updated, the application starts, and a final health check verifies the release

For example, before starting the new version, you might run:

docker compose run --rm app npm run migrate

For a Python project:

docker compose run --rm app python manage.py migrate

Production migrations require extra care.

Ideally, schema changes should remain compatible with both the new and previous application versions during the transition period.

Otherwise, rolling back only the application container may no longer be possible.

How to Prevent Concurrent Production Deployments

Suppose two commits reach main almost simultaneously.

Without additional protection, two deployment jobs could start at the same time.

Our workflow avoids this with:

concurrency:
group: production
cancel-in-progress: false

GitHub will not run multiple jobs from the same production concurrency group simultaneously.

This is especially important if your deployment pipeline includes:

  • database migrations;
  • long-running deployment scripts;
  • restarting multiple services;
  • database operations.

How to Reduce Deployment Downtime

A regular:

docker compose up -d

deployment can create a short gap between the old container stopping and the new one becoming ready.

For smaller applications, this may be acceptable.

If the project requires near-zero downtime, you can move to more advanced deployment strategies.

For example:

Blue-Green Deployment

Users

Incoming user traffic

Load Balancer

Switch traffic between versions
↙ ↘

Blue v1

Current stable version

Green v2

New application version
With Blue-Green Deployment, the new version runs alongside the current one, and the load balancer switches traffic only after the Green environment has been verified

The new version is launched in parallel with the old one.

After verification, the load balancer routes users to Green.

If a problem occurs, traffic can immediately be switched back to Blue.

Another approach is a Rolling Update, where application instances are replaced gradually.

These strategies are commonly used with Kubernetes.

When Docker Compose Is No Longer Enough

Docker Compose works well for:

  • small production projects;
  • personal services;
  • MVPs;
  • backend applications;
  • several related containers;
  • staging environments.

As your infrastructure grows, you may need:

  • multiple application nodes;
  • automatic recovery;
  • rolling updates;
  • autoscaling;
  • service discovery;
  • load balancing;
  • centralized secret management.

At that point, Kubernetes may be the next step.

The overall CI/CD concept remains almost the same:

Git Repository

Project source code

CI

Testing and build

Docker Image

Ready-to-deploy application image

Container Registry

Store Docker image versions

CD

Automated deployment

Kubernetes Cluster

Run and manage containers
CI builds and publishes the Docker image, after which CD automatically deploys the new application version to the Kubernetes cluster

The main difference is the final deployment stage.

GitHub Actions Is Not the Only CI/CD Option

The architecture described in this guide is not tied to GitHub.

Instead of GitHub Actions, you can use:

  • GitLab CI/CD;
  • Jenkins;
  • TeamCity;
  • CircleCI;
  • Bitbucket Pipelines;
  • Azure Pipelines;
  • self-hosted runners.

In most cases, the workflow remains conceptually similar:

Repository

Project source code

CI Runner

Execute the CI/CD pipeline

Tests

Automated code validation

Build Artifact

Build the application or image

Registry

Store ready artifacts

Deployment

Deliver the new version

Server

Running application version
A universal CI/CD workflow: from source code to automated application deployment on a server

Understanding the CI/CD architecture itself is therefore more important than learning the syntax of any single platform.

Common CI/CD Setup Errors

Permission denied (publickey)

GitHub Actions cannot connect to the server:

Permission denied (publickey)

Check:

  • the contents of SERVER_SSH_KEY;
  • whether the public key exists in authorized_keys;
  • the SSH username;
  • permissions on the ~/.ssh directory;
  • the SSH port.

Permission denied when using Docker

For example:

permission denied while trying to connect to the Docker daemon socket

Make sure the deployment user belongs to the:

docker

group.

Check with:

groups deploy

After adding a user to the group, start a new login session before testing Docker again.

pull access denied

An error such as:

pull access denied for myapp

usually means:

  • the image name is incorrect;
  • the repository is private;
  • the server is not authenticated with Docker Hub;
  • registry credentials are incorrect.

The Container Exits Immediately

Check:

docker ps -a

Then:

docker logs myapp

or:

docker compose --env-file .deploy.env logs app

The New Image Was Downloaded but the Website Does Not Work

Check:

  • container logs;
  • the application port;
  • Nginx configuration;
  • environment variables;
  • database connectivity;
  • firewall rules;
  • the health endpoint.

How CI/CD Should Evolve as a Project Grows

You do not need to build a complex DevOps platform from day one.

You can start with a simple pipeline:

Stage 1
Tests + SSH Deployment
Stage 2
Docker Registry + SHA Tags + Rollback
Stage 3
Staging + Production + Approval
Stage 4
Security Scans + Monitoring + Automated Rollback
Stage 5
Blue-Green / Kubernetes / Multi-node Infrastructure

The key is to automate a process that is already repeatable and understood.

The complexity of the pipeline should grow together with the complexity of the project.

Conclusion

CI/CD turns deployment from a sequence of manual operations into a repeatable automated process.

In the architecture we built, a developer only needs to change the code and push it to GitHub.

GitHub Actions then:

  1. retrieves the code;
  2. installs dependencies;
  3. runs tests;
  4. builds a Docker image;
  5. tags it with the current commit SHA;
  6. pushes the image to Docker Hub;
  7. connects to the production server;
  8. downloads the new version;
  9. updates the container through Docker Compose.

The complete path looks like this:

git push

Push changes

GitHub

Project repository

GitHub Actions

Start the CI/CD pipeline

Tests

Automated code validation

Docker Build

Build the application image

Docker Registry

Store the ready image

SSH Deployment

Connect to and update the server

Ubuntu VPS

Production server

Production

New version available to users
The complete CI/CD process: from pushing changes to the repository to automatically updating the application on the production server

Even this relatively simple pipeline already provides several advantages:

  • a consistent deployment process for every release;
  • automatic code validation;
  • fewer manual operations;
  • deployment history;
  • a direct link between the production version and a Git commit;
  • fast rollback to a previous release;
  • a foundation for staging, Kubernetes, and more advanced DevOps infrastructure.

Launch Your CI/CD Production Environment with Serverspace

A CI/CD pipeline needs an always-available server where the production version of the application can run.

With Serverspace, you can deploy an Ubuntu cloud VPS and configure Docker, Nginx, databases, and the other components of your production stack.

You can start with a single server:

GitHub Actions

CI/CD Pipeline

Serverspace VPS

Production server

Nginx

Reverse proxy and HTTPS

Docker

Container runtime

Application

Production application

Database

Data storage
GitHub Actions automatically updates the application on the VPS, where Nginx handles incoming traffic and Docker runs the application and related services

As the project grows, you can split the infrastructure:

GitHub Actions

CI/CD Pipeline

Serverspace Cloud

Production and staging infrastructure

Application Server

Production application

Database Server

Database

Staging Server

Testing environment

Monitoring

Metrics and service health
GitHub Actions automatically delivers new application versions to the Serverspace cloud infrastructure

The same CI/CD model can therefore support both a small application and a more advanced multi-server infrastructure.

Deploy a VPS with Serverspace and build an automated path from a Git commit to a running application in production.

You might also like...

We use cookies to make your experience on the Serverspace better. By continuing to browse our website, you agree to our
Use of Cookies and Privacy Policy.