From code to container: Lean VPS deployment

Automating your deployments using GitHub Actions and the GitHub Container Registry (GHCR) is one of the most effective ways to stabilize your infrastructure. By building your Docker images on GitHub’s runners and pushing them to GHCR, you completely offload the CPU-intensive build process from your VPS. It also leaves you with cleanly versioned images, making rollbacks trivial.

Here is how to set up a secure, automated pipeline for your containerized applications.

1. Enforce least privilege with a dedicated deploy user

Never give GitHub Actions SSH access to your primary user or root. Instead, create a severely restricted github-actions user on your server that is only allowed to pull images and restart containers.

SSH into your server and run the following to create the user, add them to the Docker group, and restrict their scope:

# Add service account without password or shell
sudo adduser --disabled-password --gecos "GitHub Actions deploy" github-actions

# Add service account to the docker group
sudo usermod -aG docker github-actions

# Give ownership of the required app directory to the service account
sudo chown -R github-actions:github-actions /opt/docker/myapp

This user cannot escalate privileges via sudo or modify system configurations. If you host multiple apps, assign ownership on a strict per-directory basis.

2. Configure SSH Authentication

Generate a dedicated ED25519 key pair locally specifically for this pipeline:

ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/github_actions_deploy -N ""

Copy the public key to your server. Because the github-actions user has no password, you must set this up using your existing admin user:

sudo mkdir -p /home/github-actions/.ssh
sudo cp ~/.ssh/github_actions_deploy.pub /home/github-actions/.ssh/authorized_keys
sudo chown -R github-actions:github-actions /home/github-actions/.ssh
sudo chmod 700 /home/github-actions/.ssh
sudo chmod 600 /home/github-actions/.ssh/authorized_keys

In your GitHub repository, navigate to Settings > Secrets and variables > Actions and add two secrets:

  • SERVER_IP: Your VPS IP address.
  • SSH_PRIVATE_KEY: The complete contents of your private key file (~/.ssh/github_actions_deploy).

3. Prepare the Server Environment

Next, update your server’s docker-compose.yml (located at /opt/docker/myapp/) to point to the GHCR registry instead of a local build context.

services:
  app:
    image: ghcr.io/YOUR_GITHUB_USER/YOUR_REPO:latest
    container_name: myapp
    restart: unless-stopped
    networks:
      - proxy

networks:
  proxy:
    external: true

Note: GHCR paths are always strictly lowercase.

To allow your server to pull private packages, generate a GitHub Personal Access Token (PAT) with the read:packages scope. Authenticate Docker on the server as the github-actions user:

sudo -u github-actions docker login ghcr.io -u YOUR_GITHUB_USER --password-stdin <<< "YOUR_GITHUB_PAT"

4. The GitHub Actions Workflow

Create the pipeline file at .github/workflows/deploy.yml.

If you strictly follow test-driven development principles, this is exactly where you would insert a testing job to run your suite and fail the pipeline before a build is ever triggered.

Here is the core deployment configuration:

name: Build and Deploy
on:
  push:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/metadata-action@v5
        id: meta
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=raw,value=latest
            type=sha

      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          # build-args: |
          #   VITE_API_URL=${{ secrets.VITE_API_URL }}

  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to server
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_IP }}
          username: github-actions
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /opt/docker/myapp
            docker compose pull
            docker compose up -d
            docker image prune -f

      - name: Health check
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_IP }}
          username: github-actions
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            sleep 5
            STATUS=$(docker exec nginx-proxy-manager curl -s -o /dev/null -w "%{http_code}" http://myapp:80)
            if [ "$STATUS" != "200" ]; then
              echo "Health check failed with status $STATUS"
              exit 1
            fi
            echo "Health check passed"

5. Rollbacks and Visibility

Because the docker/metadata-action step tags every build with its commit SHA, rolling back a failed deployment is straightforward.

If the health check fails:

  1. SSH into the server as github-actions.
  2. Edit /opt/docker/myapp/docker-compose.yml.
  3. Change the image tag from :latest to the last known-good SHA (e.g., :sha-a1b2c3d).
  4. Run docker compose pull && docker compose up -d.

Finally, remember that GHCR package visibility mirrors your repository. If your repo is private but you want the image to be public (to avoid needing a PAT on the server), you can change this in GitHub > Your profile > Packages > Package settings.