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 mainThe CI/CD system can then automatically:
- retrieve the source code;
- install dependencies;
- run tests;
- build a Docker image;
- publish it to a container registry;
- connect to the server;
- download the new application version;
- 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
GitHub Actions
Docker Registry
Ubuntu VPS
Production
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 initAdd 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 mainReplace 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
Pull Request
CI: tests
main
CI: tests + build
CD: production deploy
This way, a regular pull request triggers validation but does not modify production.
Step 2. Add a Dockerfile
Create a file named:
Dockerfilein 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:
.dockerignoreFor example:
node_modules
npm-debug.log
.git
.github
.env
.env.*
coverage
README.mdIt 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:testThe 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_IPUpdate the system:
apt update
apt upgrade -yInstall the required packages:
apt install -y ca-certificates curlCreate a directory for APT keys:
install -m 0755 -d /etc/apt/keyringsAdd 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
EOFRefresh the package index:
apt updateInstall Docker Engine and Docker Compose:
apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-pluginCheck the Docker service:
systemctl status dockerCheck the Docker Compose version:
docker compose versionStep 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 deployAdd it to the Docker group:
usermod -aG docker deployCreate the application directory:
mkdir -p /opt/myapp
chown -R deploy:deploy /opt/myappNow exit the root session and reconnect as the new user:
ssh deploy@SERVER_IPCheck Docker access:
docker psIf 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_deployThis creates two files:
github_actions_deploy
github_actions_deploy.pubThe 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_IPAlternatively, add its contents manually to:
/home/deploy/.ssh/authorized_keysTest the connection:
ssh -i github_actions_deploy deploy@SERVER_IPWe 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:
Serverspace VPS
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/myappCreate:
compose.yamlAdd:
services:
app:
image: yourdockerhubuser/myapp:${APP_TAG:-latest}
container_name: myapp
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
env_file:
- app.envReplace:
yourdockerhubuser/myappwith 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.envFor example:
NODE_ENV=production
DATABASE_URL=postgresql://user:password@database:5432/app
API_KEY=change_meRestrict file permissions:
chmod 600 /opt/myapp/app.envThe 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/myappFor CI/CD, use an access token instead of your primary account password.
If the repository is private, authenticate the production server once:
docker loginAfter successful authentication, the server will be able to run:
docker pull yourdockerhubuser/myapp:latestIf 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
→ ActionsCreate 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_IPAdd 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/workflowsInside it, create:
deploy.ymlAdd 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:
testIt runs for both pull requests and pushes.
GitHub creates a temporary runner:
runs-on: ubuntu-latestIt then checks out the repository:
- uses: actions/checkout@v6Sets up Node.js:
- uses: actions/setup-node@v7
with:
node-version: "22"
cache: "npm"Installs dependencies:
npm ciAnd runs the test suite:
npm testIf 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: testIt 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-stdinIt 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:
1a2b3cOn Tuesday, you deploy:
4d5e6fAfter the second deployment, a problem appears.
If the server only uses:
myapp:latestit may not be immediately obvious which exact image version was running before the update.
With SHA tags:
myapp:1a2b3c
myapp:4d5e6feach version can be identified unambiguously.
That is why the workflow writes the current commit SHA to:
/opt/myapp/.deploy.envFor 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: buildGitHub Actions prepares the SSH configuration and runs:
ssh "$SERVER_USER@$SERVER_HOST"On the remote server, the pipeline moves to:
/opt/myappIt records the image tag:
APP_TAG=$GITHUB_SHAThen runs:
docker compose --env-file .deploy.env pullDocker downloads the new image.
After that:
docker compose --env-file .deploy.env up -d --remove-orphansDocker 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-apiMakes changes and pushes them:
git add .
git commit -m "Add new API endpoint"
git push origin feature/new-apiAfter a pull request is opened:
If all checks succeed, the pull request can be merged into main.
After the merge:
main
Tests
Docker Build
Docker Hub
SSH
VPS
docker compose pull
docker compose up
Production
Everything after the merge happens automatically.
Step 13. Verify the First Deployment
After merging, open:
GitHub
→ Actions
→ CI/CDYou should see three jobs:
Test application
Build and push Docker image
Deploy to productionThey should complete successfully in sequence.
Then connect to the server:
ssh deploy@SERVER_IPCheck the running container:
docker psThe output should look similar to:
CONTAINER ID IMAGE STATUS
5ae62b43d810 yourdockerhubuser/myapp:abc123 Up 1 minuteCheck the saved deployment tag:
cat /opt/myapp/.deploy.envFor example:
APP_TAG=abc123...To view application logs:
cd /opt/myapp
docker compose --env-file .deploy.env logs -fHow 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:3000This means port 3000 is only accessible locally on the server.
You can place Nginx in front of the application.
Install it:
sudo apt install nginxCreate a configuration:
sudo nano /etc/nginx/sites-available/myappExample:
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/myappTest the configuration:
sudo nginx -tReload Nginx:
sudo systemctl reload nginxThe production architecture now looks like this:
Internet
Nginx :80 / :443
127.0.0.1:3000
Docker Container
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=4d5e6fand the previous stable version is:
APP_TAG=1a2b3cTo roll back, connect to the server:
ssh deploy@SERVER_IPGo to the application directory:
cd /opt/myappSet the previous image tag:
echo "APP_TAG=1a2b3c" > .deploy.envPull the image:
docker compose --env-file .deploy.env pullRestart the application:
docker compose --env-file .deploy.env up -d --remove-orphansProduction 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/healthadd the following step after SSH deployment:
- name: Verify deployment
run: |
sleep 10
curl --fail --silent --show-error
https://example.com/healthThe endpoint might return:
HTTP/1.1 200 OKand, 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
Pull Request
CI
develop
Staging
Verification
main
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:
productionand configure protection rules for it.
Then add the environment to the deployment job:
environment:
name: productionThe 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
Stop
Build
Stop
Deploy
Deployment should never run independently of CI results.
In GitHub Actions, this dependency is implemented with:
needs: testand:
needs: buildWhat 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
Install Dependencies
Lint
Unit Tests
Integration Tests
Security Scan
Docker Build
Container Scan
Publish Artifact
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
Deploy Image
Database Migration
Start Application
Health Check
For example, before starting the new version, you might run:
docker compose run --rm app npm run migrateFor a Python project:
docker compose run --rm app python manage.py migrateProduction 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: falseGitHub 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 -ddeployment 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
Load Balancer
Blue v1
Green v2
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
CI
Docker Image
Container Registry
CD
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
CI Runner
Tests
Build Artifact
Registry
Deployment
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 socketMake sure the deployment user belongs to the:
dockergroup.
Check with:
groups deployAfter 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 myappusually 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 -aThen:
docker logs myappor:
docker compose --env-file .deploy.env logs appThe 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:
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:
- retrieves the code;
- installs dependencies;
- runs tests;
- builds a Docker image;
- tags it with the current commit SHA;
- pushes the image to Docker Hub;
- connects to the production server;
- downloads the new version;
- updates the container through Docker Compose.
The complete path looks like this:
git push
GitHub
GitHub Actions
Tests
Docker Build
Docker Registry
SSH Deployment
Ubuntu VPS
Production
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
Serverspace VPS
Nginx
Docker
Application
Database
As the project grows, you can split the infrastructure:
GitHub Actions
Serverspace Cloud
Application Server
Database Server
Staging Server
Monitoring
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.