...

How to Set Up Automated Deployment to a VPS with GitHub Actions or GitLab CI/CD

Martin Klein

Reading time 1 minute

Zero-downtime deployment can be built around a releases directory and a current symbolic link: the new version of the application is uploaded to a separate directory and prepared there, after which current is switched to the new release. If the health check fails, the link is automatically switched back to the previous working version.

In this guide, we will configure a separate user and a deploy key without administrative access, build and transfer the artifact, set up a health check and rollback, and then show two CI/CD pipeline options: one for GitHub Actions and one for GitLab CI/CD.

How Zero-Downtime Deployment Works

One simple way to update an application without extended downtime is to store each version in a separate directory and use a symbolic link named current that points to the active release.

The releases directory and the current symbolic link

The application structure might look like this:

Each new deployment gets its own directory inside releases. The running service starts the application using the /var/www/app/current path, while the current link itself is switched to the target version.

You can check the active release with the following command: readlink -f /var/www/app/current

Successful deployment and rollback scenario

In a standard deployment, CI/CD performs several steps in sequence:

  1. Builds the application into an artifact.
  2. Transfers the artifact to the VPS.
  3. Creates a new directory in releases.
  4. Extracts the new version into it.
  5. Switches current to the new release.
  6. Restarts the application.
  7. Runs a health check.

If the new version responds correctly, the release remains active. If the health check fails, the script switches current back to the previous directory and starts the application again.

This approach avoids overwriting the files of the running version during deployment and makes it easier to return to the previous state.

Preparing the VPS

You should not use the root account for CI/CD. Create a separate deploy user with access only to the application directories and the deployment operations required.

Creating a dedicated deploy user

Create the user: sudo adduser –disabled-password –gecos "" deploy

You can verify the user with the command: id deploy

The user will have its own home directory, /home/deploy, but will not be granted administrative privileges.

Restricting Administrative Privileges

Do not add the deploy user to the sudo or admin groups.

You can check the user’s groups with the following command: groups deploy

You can also verify that direct sudo invocation is denied: sudo -u deploy sudo -n true

For a user without the required privileges, the command will fail.

If deployment requires restarting a specific systemd service, it is safer to allow only that operation with a separate sudoers rule rather than granting full administrative access.

For example: sudo visudo -f /etc/sudoers.d/deploy-app

Then add: deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart app.service

This allows CI/CD to restart only app.service without granting arbitrary root access.

Creating the application and release directories

Create the base directory structure: sudo mkdir -p /var/www/app/releases

Transfer ownership to the deploy user: sudo chown -R deploy:deploy /var/www/app

Create the first test release: sudo -u deploy mkdir -p /var/www/app/releases/initial

And create the current symbolic link: sudo -u deploy ln -s /var/www/app/releases/initial /var/www/app/current

You can verify the structure with the following commands:

ls -la /var/www/app

ls -la /var/www/app/releases

Configuring SSH Access for CI/CD

CI/CD will connect to the VPS using a separate SSH key. This key is intended only for automated deployments and must not be the same as the personal administrative key.

Creating a dedicated deploy key

On your local machine or in a secure administrative environment, generate a new key pair: ssh-keygen -t ed25519 -f deploy_key -C "ci-deploy"

This will create two files:

deploy_key

deploy_key.pub

The deploy_key file is the private key and is later stored in Secrets or CI/CD Variables.

The deploy_key.pub file is added to the VPS.

Adding the Public Key to the VPS

Create the user’s SSH directory: sudo mkdir -p /home/deploy/.ssh

Open (or create) the file: sudo nano /home/deploy/.ssh/authorized_keys

Add the contents of deploy_key.pub.

Then set the correct permissions:

sudo chown -R deploy:deploy /home/deploy/.ssh

sudo chmod 700 /home/deploy/.ssh

sudo chmod 600 /home/deploy/.ssh/authorized_keys

Restricting deploy key capabilities

Additional restrictions can be set directly before the key in authorized_keys.

For example: no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-pty ssh-ed25519 AAAA… ci-deploy

This entry disables agent forwarding, port forwarding, X11 forwarding, and an interactive pseudo-terminal.

If necessary, access can be restricted even further: for example, you can allow connections only from a specific address or force the key to run a specific deployment script using the command option.

Even then, the deploy user still does not receive full administrative access to the server.

Verifying SSH access as the deploy user

Check the connection using the private key: ssh -i deploy_key [email protected]

If the no-pty option is set for the key, a full interactive shell may be unavailable. In this case, it is useful to check execution of a single command: ssh -i deploy_key [email protected] "whoami && id"

Expected result:

deploy

uid=1001(deploy) gid=1001(deploy) groups=1001(deploy)

Also verify that unrestricted sudo access is not available: ssh -i deploy_key [email protected] "sudo -n id"

The command should fail with a denial if the user is allowed to perform only specifically defined operations.

Preparing the Application for Automated Deployment

Before configuring GitHub Actions or GitLab CI/CD, you need to prepare the application itself for safe switching between releases. To do this, the service must start via the current symbolic link rather than from a hard-coded directory for a specific version.

In addition, the application needs a dedicated health endpoint. The CI/CD pipeline will call it immediately after switching to the release and use the health check result to decide whether to keep the new version active or perform a rollback.

Creating a health endpoint

A health endpoint is a simple HTTP route that lets you automatically check whether the application has started and can respond to requests.

For this example, we will use a small FastAPI application. Create the file: nano /var/www/app/releases/initial/main.py

Add the following:

from fastapi import FastAPI

app = FastAPI()

@app.get("/")

def root():

return {

"message": "Application is running",

"version": "initial"

}

@app.get("/health")

def health():

return {"status": "ok"}

The health endpoint will be available at: http://127.0.0.1:8000/health

For a basic check, an HTTP 200 response is sufficient. In a real project, a health check can also check the availability of the database, Redis, a message queue, or other critical dependencies.

To run the application later, install FastAPI and Uvicorn inside the first release:

cd /var/www/app/releases/initial

python3 -m venv venv

source venv/bin/activate

pip install fastapi uvicorn

Save the dependencies: pip freeze > requirements.txt

The first release now contains the application and the Python packages required to run it.

Configuring a systemd service

To make the application start automatically when the VPS starts and allow it to be restarted after switching the release, create a systemd service.

It is important that its configuration uses the path /var/www/app/current rather than a specific directory inside releases. This way, the unit file will not need to be changed with each new deployment.

Create the file: sudo nano /etc/systemd/system/app.service

Add:

[Unit]

Description=Application service

After=network.target

[Service]

User=deploy

Group=deploy

WorkingDirectory=/var/www/app/current

ExecStart=/var/www/app/current/venv/bin/uvicorn main:app –host 127.0.0.1 –port 8000

Restart=always

RestartSec=3

[Install]

WantedBy=multi-user.target

Reload the systemd configuration: sudo systemctl daemon-reload

Enable the service to start automatically: sudo systemctl enable app.service

Previously, the deploy user was allowed to run only this specific command: /usr/bin/systemctl restart app.service

This allows CI/CD to restart the application after deployment without giving the user full sudo access.

Starting the application via current

Check where the symbolic link currently points: readlink -f /var/www/app/current

For the first release, the result will look something like this: /var/www/app/releases/initial

Start the service: sudo systemctl start app.service

Check its status: sudo systemctl status app.service –no-pager

Then send a request to the health endpoint: curl http://127.0.0.1:8000/health

Expected response: {“status”:”ok”}

At this point, the application is already running via current. Therefore, for the next deployment, you only need to prepare a new directory, switch the symbolic link, and restart app.service.

Building and deploying a release

Now let’s look at the update process itself. Instead of copying files directly over the running application, the CI/CD pipeline first creates a separate artifact, transfers it to the VPS, and deploys it to a new directory under releases.

The currently running version remains in place until current is switched over.

Creating an Application Artifact

An artifact is an archive containing the files for a specific version of an application, created during the CI stage.

For example, for a small Python project, it might include:

main.py

requirements.txt

In the source project directory, create an archive: tar -czf release.tar.gz main.py requirements.txt

The venv virtual environment directory is usually not included in the artifact. It can take up a lot of space and contain files that depend on the environment in which it was created.

Instead, dependencies are pinned in requirements.txt and installed directly during release preparation on the VPS.

Transferring the Artifact to the VPS

The CI/CD pipeline can transfer the generated archive to the server using scp.

For example: scp -i deploy_key release.tar.gz [email protected]:/tmp/release.tar.gz

In GitHub Actions or GitLab CI/CD, a private key stored in Secrets or CI/CD Variables is used instead of a local path to deploy_key.

The deploy user is granted only the ability to upload the file and work within the application directory. Full administrative SSH access is not required for this.

Extracting into a new releases directory

For each release, it is convenient to use a unique name, such as a timestamp: RELEASE_ID=$(date +%Y%m%d%H%M%S)

Create a new directory: mkdir -p /var/www/app/releases/$RELEASE_ID

Extract the artifact into it: tar -xzf /tmp/release.tar.gz -C /var/www/app/releases/$RELEASE_ID

Then prepare the Python environment:

cd /var/www/app/releases/$RELEASE_ID

python3 -m venv venv

source venv/bin/activate

pip install -r requirements.txt

As a result, the new release is fully prepared in a separate directory, but it is not yet serving user requests.

This is an important point: the old version continues to run via current while the new version is still being assembled and its dependencies are being installed.

Switching the current symbolic link

Before switching, save the path to the current working release: PREVIOUS_RELEASE=$(readlink -f /var/www/app/current)

Then atomically replace the symbolic link: ln -sfn /var/www/app/releases/$RELEASE_ID /var/www/app/current

Verify the result: readlink -f /var/www/app/current

current should now point to the new directory: /var/www/app/releases/20260811140000

To check the structure, run: ls -la /var/www/app

After the link is switched, the files from the previous version are not deleted. They remain in releases, so you can quickly revert to them if needed.

Health Check and Automatic Rollback

Switching current does not in itself mean that the new release is actually functional. For example, the application may exit on startup because of a syntax error, a missing dependency, or incorrect configuration.

Therefore, immediately after activating the new version, CI/CD should restart the service and perform a health check.

Verifying the application after switching

Restart the application: sudo systemctl restart app.service

Give the service a few seconds to start: sleep 3

Now check the health endpoint: curl –fail http://127.0.0.1:8000/health

The –fail option makes curl exit with a non-zero status code if the server returns an HTTP error. This is important for CI/CD: the pipeline can determine that the check failed.

If the deployment is successful, you will get: {“status”:”ok”}

It is more convenient to add several retries, because the application may not start immediately:

for i in {1..10}; do

if curl –fail –silent http://127.0.0.1:8000/health; then

echo

echo “Health check passed”

exit 0

fi

sleep 2

done

echo “Health check failed”

exit 1

This gives the pipeline up to 20 seconds for the application to start successfully before treating the release as faulty.

Rolling back to the previous release on error

If the new release does not pass the health check, current must be pointed back to the previously saved PREVIOUS_RELEASE path.

The rollback logic might look like this:

if ! curl –fail –silent http://127.0.0.1:8000/health; then

echo "Health check failed. Rolling back…"

ln -sfn "$PREVIOUS_RELEASE" /var/www/app/current

sudo systemctl restart app.service

echo "Rollback completed"

fi

After restoring the symlink, you can check the application again: curl –fail http://127.0.0.1:8000/health

You can also verify that current points to the previous release again: readlink -f /var/www/app/current

In a full deployment script, it is better to combine multiple health check attempts and the rollback into a single sequence:

HEALTH_OK=0

for i in {1..10}; do

if curl –fail –silent http://127.0.0.1:8000/health > /dev/null; then

HEALTH_OK=1

break

fi

sleep 2

done

if [ "$HEALTH_OK" -ne 1 ]; then

echo "Health check failed. Rolling back to $PREVIOUS_RELEASE"

ln -sfn "$PREVIOUS_RELEASE" /var/www/app/current

sudo systemctl restart app.service

sleep 3

curl –fail http://127.0.0.1:8000/health

exit 1

fi

echo "Deployment completed successfully"

Here, a failed health check automatically rolls the application back to the previous version, after which the pipeline exits with an error. This keeps the application running while also indicating in CI/CD that the new release was not accepted.

Automated Deployment with GitHub Actions

Once the server-side part of the setup is ready, the manual steps can be moved to GitHub Actions. The workflow will run after changes to the main branch, package the application files into an archive, connect to the VPS as the restricted deploy user, and execute the same procedure that was previously tested manually.

The GitHub Actions configuration is stored in a YAML file inside the .github/workflows directory. Secret values do not need to be written directly to the repository: GitHub allows them to be passed to the workflow through Secrets.

Which secrets are required for the workflow

This setup requires four values:

VPS_HOST

VPS_USER

SSH_PRIVATE_KEY

SSH_KNOWN_HOSTS

VPS_HOST contains the server address, for example 203.0.113.10, while VPS_USER is the name of the user created earlier: deploy

SSH_PRIVATE_KEY stores the private part of a dedicated deploy key. This key is used only for CI/CD and corresponds to the public key in /home/deploy/.ssh/authorized_keys.

SSH_KNOWN_HOSTS contains the server’s SSH host key. It is needed so the runner can verify that it is connecting to the expected VPS rather than disabling server authenticity checks.

You can obtain the entry in advance from a trusted machine: ssh-keyscan -H 203.0.113.10

Then save the resulting value as a separate secret. This approach is preferable to using StrictHostKeyChecking=no: SSH continues to verify the server on every connection.

The private key itself should not be added to the source code or the YAML file. GitHub Secrets are designed to provide sensitive values to a workflow without storing them directly in the repository.

Creating the deploy.yml workflow file

Create the following file in the repository: .github/workflows/deploy.yml

A basic configuration might look like this:

name: Deploy to VPS

on:

push:

branches:

– main

jobs:

deploy:

runs-on: ubuntu-latest

steps:

– name: Checkout repository

uses: actions/checkout@v4

– name: Build release artifact

run: |

tar -czf release.tar.gz main.py requirements.txt

– name: Configure SSH

env:

SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}

SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}

run: |

mkdir -p ~/.ssh

chmod 700 ~/.ssh

printf ‘%s\n’ "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key

chmod 600 ~/.ssh/deploy_key

printf ‘%s\n’ "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts

chmod 600 ~/.ssh/known_hosts

– name: Upload artifact

env:

VPS_HOST: ${{ secrets.VPS_HOST }}

VPS_USER: ${{ secrets.VPS_USER }}

run: |

scp -i ~/.ssh/deploy_key \

release.tar.gz \

"$VPS_USER@$VPS_HOST:/tmp/release-${GITHUB_SHA}.tar.gz"

– name: Deploy release

env:

VPS_HOST: ${{ secrets.VPS_HOST }}

VPS_USER: ${{ secrets.VPS_USER }}

run: |

ssh -i ~/.ssh/deploy_key "$VPS_USER@$VPS_HOST" \

&quot;RELEASE_ID=${GITHUB_SHA} bash -s&quot; <<‘DEPLOY’

set -e

APP_DIR=&quot;/var/www/app&quot;

RELEASE_DIR=&quot;$APP_DIR/releases/$RELEASE_ID&quot;

ARCHIVE=&quot;/tmp/release-$RELEASE_ID.tar.gz&quot;

PREVIOUS_RELEASE=$(readlink -f &quot;$APP_DIR/current&quot;)

mkdir -p &quot;$RELEASE_DIR&quot;

tar -xzf &quot;$ARCHIVE&quot; -C &quot;$RELEASE_DIR&quot;

cd &quot;$RELEASE_DIR&quot;

python3 -m venv venv

./venv/bin/pip install -r requirements.txt

ln -sfn &quot;$RELEASE_DIR&quot; &quot;$APP_DIR/current&quot;

sudo /usr/bin/systemctl restart app.service

HEALTH_OK=0

for i in {1..10}; do

if curl –fail –silent \

http://127.0.0.1:8000/health > /dev/null; then

HEALTH_OK=1

break

fi

sleep 2

done

if [ &quot;$HEALTH_OK&quot; -ne 1 ]; then

echo &quot;Health check failed. Rolling back…&quot;

ln -sfn &quot;$PREVIOUS_RELEASE&quot; &quot;$APP_DIR/current&quot;

sudo /usr/bin/systemctl restart app.service

sleep 3

curl –fail http://127.0.0.1:8000/health

rm -f &quot;$ARCHIVE&quot;

exit 1

fi

echo &quot;Deployment completed successfully&quot;

readlink -f &quot;$APP_DIR/current&quot;

rm -f &quot;$ARCHIVE&quot;

DEPLOY

The workflow runs after a push to the main branch. GitHub Actions lets you define trigger events and the sequence of jobs and steps directly in the workflow file.

Here, the Git commit identifier GITHUB_SHA is also used as the release name. As a result, directories on the VPS can be associated with specific versions of the source code:

/var/www/app/releases/3f2a8c…

/var/www/app/releases/8db419…

Building the artifact and sending it to the VPS

In the Build release artifact step, the project files are packaged: tar -czf release.tar.gz main.py requirements.txt

For a larger application, the archive can include the source code directory, configuration templates, and other resources required to run the application instead of individual files.

The key principle is that the runner first creates a complete release build and only then transfers it to the server.

In this workflow, the transfer is performed via scp:

scp -i ~/.ssh/deploy_key \

release.tar.gz \

&quot;$VPS_USER@$VPS_HOST:/tmp/release-${GITHUB_SHA}.tar.gz&quot;

The key exists only within the job environment and is used to connect as deploy. Even if the CI/CD command is executed remotely, the user’s capabilities on the VPS remain limited by the previously configured file permissions and sudoers rule.

If necessary, the built files can also be stored as GitHub workflow artifacts: GitHub provides separate mechanisms for uploading artifacts and passing them between jobs. For a simple VPS deployment in this example, additional storage is not required: the archive is sent directly to the server.

Release switching, health check, and rollback

After connecting to the VPS, the workflow stores the path to the current working version: PREVIOUS_RELEASE=$(readlink -f &quot;$APP_DIR/current&quot;)

It then creates a new directory, extracts the archive into it, and installs the dependencies:

mkdir -p &quot;$RELEASE_DIR&quot;

tar -xzf &quot;$ARCHIVE&quot; -C &quot;$RELEASE_DIR&quot;

cd &quot;$RELEASE_DIR&quot;

python3 -m venv venv

./venv/bin/pip install -r requirements.txt

Only after the new version has been prepared successfully is current switched: ln -sfn &quot;$RELEASE_DIR&quot; &quot;$APP_DIR/current&quot;

The service is restarted using a command permitted for the deploy user: sudo /usr/bin/systemctl restart app.service

After that, the workflow checks /health up to ten times. If the application starts returning a successful response, the release is considered operational.

If the check fails, the switch is rolled back:

ln -sfn &quot;$PREVIOUS_RELEASE&quot; &quot;$APP_DIR/current&quot;

sudo /usr/bin/systemctl restart app.service

As a result, an error in the new version causes the workflow itself to fail, but the previous release becomes active again.

Automated deployment with GitLab CI/CD

The same server-side script can be used with GitLab CI/CD. The main differences are the pipeline syntax and how variables are passed, while the releases structure, symbolic link, health check, and rollback on the VPS remain the same.

GitLab defines a pipeline in the .gitlab-ci.yml file located in the repository. Jobs, stages, artifacts, and other parameters are described in the YAML configuration.

Which CI/CD variables are required for the pipeline

Create the following CI/CD variables:

VPS_HOST

VPS_USER

SSH_PRIVATE_KEY

SSH_KNOWN_HOSTS

The purpose of the first three is the same as in GitHub Actions.

For SSH keys, GitLab specifically recommends storing the key and known_hosts in CI/CD variables, and obtaining host keys in advance from a trusted network rather than running ssh-keyscan directly inside the job. This protects the runner from connecting to a spoofed server.

Sensitive values, including the private key, should not be written directly in .gitlab-ci.yml: GitLab states that secret values should be stored in the CI/CD variables settings, while variables defined in YAML are accessible to users with access to the repository.

Creating the .gitlab-ci.yml file

Create .gitlab-ci.yml in the root directory of the repository.

Add the following configuration:

stages:

– build

– deploy

build:

stage: build

image: alpine:latest

before_script:

– apk add –no-cache tar

script:

– tar -czf release.tar.gz main.py requirements.txt

artifacts:

paths:

– release.tar.gz

expire_in: 1 hour

deploy:

stage: deploy

image: alpine:latest

dependencies:

– build

before_script:

– apk add –no-cache openssh-client bash curl

– mkdir -p ~/.ssh

– chmod 700 ~/.ssh

– printf ‘%s\n’ &quot;$SSH_PRIVATE_KEY&quot; > ~/.ssh/deploy_key

– chmod 600 ~/.ssh/deploy_key

– printf ‘%s\n’ &quot;$SSH_KNOWN_HOSTS&quot; > ~/.ssh/known_hosts

– chmod 600 ~/.ssh/known_hosts

script:

– |

scp -i ~/.ssh/deploy_key \

release.tar.gz \

&quot;$VPS_USER@$VPS_HOST:/tmp/release-${CI_COMMIT_SHA}.tar.gz&quot;

– |

ssh -i ~/.ssh/deploy_key &quot;$VPS_USER@$VPS_HOST&quot; \

&quot;RELEASE_ID=${CI_COMMIT_SHA} bash -s&quot; <<‘DEPLOY’

set -e

APP_DIR=&quot;/var/www/app&quot;

RELEASE_DIR=&quot;$APP_DIR/releases/$RELEASE_ID&quot;

ARCHIVE=&quot;/tmp/release-$RELEASE_ID.tar.gz&quot;

PREVIOUS_RELEASE=$(readlink -f &quot;$APP_DIR/current&quot;)

mkdir -p &quot;$RELEASE_DIR&quot;

tar -xzf &quot;$ARCHIVE&quot; -C &quot;$RELEASE_DIR&quot;

cd &quot;$RELEASE_DIR&quot;

python3 -m venv venv

./venv/bin/pip install -r requirements.txt

ln -sfn &quot;$RELEASE_DIR&quot; &quot;$APP_DIR/current&quot;

sudo /usr/bin/systemctl restart app.service

HEALTH_OK=0

for i in {1..10}; do

if curl –fail –silent \

http://127.0.0.1:8000/health > /dev/null; then

HEALTH_OK=1

break

fi

sleep 2

done

if [ &quot;$HEALTH_OK&quot; -ne 1 ]; then

echo &quot;Health check failed. Rolling back…&quot;

ln -sfn &quot;$PREVIOUS_RELEASE&quot; &quot;$APP_DIR/current&quot;

sudo /usr/bin/systemctl restart app.service

sleep 3

curl –fail http://127.0.0.1:8000/health

rm -f &quot;$ARCHIVE&quot;

exit 1

fi

echo &quot;Deployment completed successfully&quot;

readlink -f &quot;$APP_DIR/current&quot;

rm -f &quot;$ARCHIVE&quot;

DEPLOY

rules:

– if: ‘$CI_COMMIT_BRANCH == &quot;main&quot;’

The pipeline consists of two stages:

build

deploy

The first creates an archive, and the second uploads it to the VPS and activates it.

GitLab lets you save job results as artifacts and pass them to subsequent jobs. That is why release.tar.gz, created at the build stage, becomes available to the deploy stage.

Building the artifact and uploading it to the VPS

At the build stage, the following command is run: tar -czf release.tar.gz main.py requirements.txt

GitLab then stores the resulting file:

artifacts:

paths:

– release.tar.gz

expire_in: 1 hour

There is no need to set a long retention period here: the archive is needed only by the next pipeline stage.

At the deploy stage, it is transferred to the VPS:

scp -i ~/.ssh/deploy_key \

release.tar.gz \

&quot;$VPS_USER@$VPS_HOST:/tmp/release-${CI_COMMIT_SHA}.tar.gz&quot;

The CI_COMMIT_SHA variable lets you use the Git commit ID as the release name.

Switching releases, health check, and rollback

After transferring the archive, GitLab runs essentially the same deployment script on the VPS as GitHub Actions does.

The sequence remains unchanged:

artifact

new release

dependencies

current → new release

restart

health check

success / rollback

This is an important property of the setup: the deployment mechanism does not depend on any specific CI/CD platform. GitHub Actions and GitLab CI/CD simply trigger a predefined sequence of actions.

If the health check fails, the pipeline sets current back to PREVIOUS_RELEASE, restarts app.service, and exits with an error. If the check succeeds, the new version remains active.

Verifying deployment without downtime

After configuring the pipeline, you should verify not only that the commands execute successfully, but also the final state of the VPS. The new release directory should be active, /health should return a successful response, and the previous version should remain in releases in case a rollback is needed.

The symbolic link is switched with a single filesystem operation, so the files of the running version are not replaced one by one. However, if the application uses a single process and systemctl restart, the process restart itself may create a very brief window of unavailability. For strict zero-downtime in high-load production systems, multiple application instances are typically used, with traffic switched between them one at a time.

Releasing a new application version

To check this, change the version number in main.py.

For example:

from fastapi import FastAPI

app = FastAPI()

@app.get(&quot;/&quot;)

def root():

return {

&quot;message&quot;: &quot;Application is running&quot;,

&quot;version&quot;: &quot;2.0&quot;

}

@app.get(&quot;/health&quot;)

def health():

return {&quot;status&quot;: &quot;ok&quot;}

After saving and pushing the changes to the main branch, CI/CD should generate a new artifact.

As a result, another directory will appear on the VPS:

The previous version is not overwritten.

Checking the active release during an update

Before switching to the new release, you can check the current symlink: readlink -f /var/www/app/current

After the deployment script finishes, run the command again: readlink -f /var/www/app/current

The path should change to the new release directory.

You can view the list of saved versions with the following command: ls -lah /var/www/app/releases

This way, the previous release remains physically present on the VPS and available for rollback.

Verifying the application after deployment

First, check the health endpoint: curl –fail http://127.0.0.1:8000/health

Expected response: {“status”:”ok”}

Now request the application version: curl http://127.0.0.1:8000/

After the test version is released, the response will look like this: {“message”:”Application is running”,”version”:”2.0″}

For the final check, it is convenient to run several commands in sequence:

echo “Active release:”

readlink -f /var/www/app/current

echo

echo “Health check:”

curl –fail http://127.0.0.1:8000/health

echo

echo “Application version:”

curl http://127.0.0.1:8000/

The combined output will show the active directory, a successful health check, and the new application version.

Maintaining the deployment setup

After automated deployment has been configured, the main tasks are monitoring the application’s health, cleaning up old releases, and periodically rotating access keys. These operations do not affect the pipeline logic, but they help keep the VPS in a predictable and secure state.

Viewing systemd logs

Because the application runs as a systemd service, you can view its logs with journalctl. This utility reads entries collected by systemd-journald.

To display the most recent entries for app.service, run: sudo journalctl -u app.service -n 50 –no-pager

To view messages in real time: sudo journalctl -u app.service -f

Logs are especially useful after a failed health check: they can show import errors, missing dependencies, application exceptions, or the reasons why the process exited.

You can also quickly check the service status with: sudo systemctl status app.service –no-pager

Removing old releases

Each deployment creates a new directory under /var/www/app/releases. Over time, older versions start taking up disk space, so it is a good idea to remove them periodically.

First, review the list of releases: ls -lt /var/www/app/releases

Do not delete the directory that current points to. You can check the active version as follows: readlink -f /var/www/app/current

For example, to keep the five most recent releases and delete older ones, you can use:

cd /var/www/app/releases

ls -1dt */ | tail -n +6 | xargs -r rm -rf

You can run this cleanup manually after several deployments, or add it as a separate final step in the deployment script.

It is useful to keep at least a few previous versions. They allow you to perform a manual rollback if an issue is discovered only after the automated health check has completed.

Rotating the deploy key and CI/CD secrets

A deploy key should not be treated as permanent. If the key may have been compromised, the team membership has changed, or a scheduled credential rotation is being performed, it is best to create a new key pair and replace the old one.

You can generate a new key with the following command: ssh-keygen -t ed25519 -f deploy_key_new -C “ci-deploy”

Add the new public key to: /home/deploy/.ssh/authorized_keys

After verifying the connection, you can remove the old entry.

The authorized_keys file is used by OpenSSH to store public keys that are allowed to authenticate as a specific user. Additional access restrictions can also be defined for each entry.

At the same time, update the private key in SSH_PRIVATE_KEY in GitHub Secrets or GitLab CI/CD Variables. GitHub provides a dedicated secrets store for workflows, while GitLab allows sensitive values to be stored in CI/CD Variables instead of adding them directly to the YAML file.

If the VPS itself or its SSH host key changes, you must also update SSH_KNOWN_HOSTS.

Conclusion

Automated deployment to a VPS can be implemented without granting the CI/CD system full administrative access. This is done using a dedicated deploy user, a separate SSH key, and the minimum set of permissions required only to deploy releases and restart a specific systemd service.

Each application version is deployed to a separate directory under releases, while the current symbolic link determines the active release. After switching releases, the pipeline runs a health check. If the new version does not start correctly, the link is switched back to the previous directory, and the application is automatically restarted with the previous working release.

The same server-side setup works for both GitHub Actions and GitLab CI/CD. The main differences are the pipeline syntax and the way secrets are stored, while the logic for building the artifact, transferring it to the VPS, switching the release, and performing a rollback remains the same.

FAQ

Why store each release in a separate directory?

This prevents the files of the running version from being overwritten directly during deployment. The new release is first fully prepared separately, and then current is switched to it. Previous versions remain available for rollback.

Why shouldn’t CI/CD connect to a VPS as root?

In that case, compromising the CI/CD key effectively gives an attacker administrative access to the entire server. A dedicated deploy user with minimal permissions limits the impact of a key leak.

If a systemd service needs to be restarted, the user can be allowed to run only that specific command via sudoers, rather than being granted full sudo access.

How does a deploy key differ from a regular administrator SSH key?

Technically, it uses the same key-based SSH authentication, but a deploy key is created specifically for automation. It is associated with a separate user with limited permissions and is not used for routine VPS administration.

OpenSSH also allows you to restrict individual entries in authorized_keys, for example by disabling port forwarding, agent forwarding, and pseudo-terminal allocation.

What happens if the health check for the new version fails?

The deployment script points current back to the previous release path, restarts the service, and fails the pipeline. As a result, CI/CD reports a failed deployment, but the working version of the application becomes active again.

Does a symbolic link provide absolute zero downtime?

Switching the link itself is almost instantaneous and does not require copying files onto the running release. However, if the application runs as a single instance and systemctl restart is executed after the switch, there may be a brief window of unavailability while the process restarts.

For strict zero downtime, teams typically use multiple application instances, rolling deployments, or a blue-green setup that switches traffic between versions that are already running.

Where should the private SSH key for the pipeline be stored?

The private key should not be added to the Git repository or written directly in deploy.yml or .gitlab-ci.yml.

In GitHub Actions, it can be stored in repository or environment secrets.

In GitLab, CI/CD variables are used for this purpose.

Should the previous release be removed immediately after a successful deployment?

No. It is better to keep several previous versions so you can quickly perform a rollback if an issue is discovered later and was not detected by the initial health check.

Sources

  1. GitHub Docs — Using secrets in GitHub Actions
  2. GitLab Docs — CI/CD variables
  3. GitLab Docs — CI/CD YAML syntax reference
  4. Systemd — journalctl

Subscribe to our newsletter and receive articles and news

    Check out our other materials