DEPLOY May 1, 2026 11 min read

GitHub Actions: Deploy an AI Bot to a VPS with Zero-Downtime CI/CD

TL;DR Use GitHub Actions to SSH into your VPS and deploy a Dockerized AI bot on every push to main. Store API keys and SSH credentials as GitHub Secrets —…

by Bugi 11 min
TL;DR

  • Use GitHub Actions to SSH into your VPS and deploy a Dockerized AI bot on every push to main.
  • Store API keys and SSH credentials as GitHub Secrets — never hardcode them.
  • Zero-downtime deploys via Docker Compose with health checks and automatic rollback.

Overview

This guide walks through deploying an AI bot — a Discord bot, Telegram bot, Slack bot, or any long-running process that calls an LLM API — to a VPS using GitHub Actions. The workflow triggers on push to main, SSHes into your server, pulls the latest image, and restarts the container with zero downtime.

You’ll have a fully automated CI/CD pipeline in about 30 minutes. The setup works with any VPS provider (Hetzner, DigitalOcean, Linode, Vultr) running Ubuntu 22.04+ and any AI bot framework (Python, Node.js, Go).

What You’ll Need

  • A VPS running Ubuntu 22.04 or later, with at least 1 GB RAM and 1 vCPU. A $5-6/month tier from most providers works fine.
  • A GitHub repository containing your bot’s source code.
  • Docker and Docker Compose installed on the VPS.
  • An SSH key pair — you’ll store the private key in GitHub Secrets.
  • API keys for your LLM provider (OpenAI, Anthropic, etc.) and bot platform (Discord token, Telegram token, etc.).
Warning
Never commit API keys, SSH keys, or .env files to your repository. Use GitHub Secrets exclusively.

Step 1: Prepare the VPS

SSH into your VPS and install Docker. If Docker is already installed, skip to the user setup.

1
Install Docker
Use the official convenience script or install from Docker’s apt repo.
2
Create a deploy user
A non-root user with Docker permissions and SSH access for GitHub Actions.
3
Configure SSH key auth
Generate a dedicated key pair for CI/CD — the private key goes into GitHub Secrets.
# Install Docker
curl -fsSL https://get.docker.com | sh

# Create deploy user and add to docker group
sudo adduser --disabled-password --gecos "" deploy
sudo usermod -aG docker deploy

# Switch to deploy user and set up SSH
sudo su - deploy
mkdir -p ~/.ssh && chmod 700 ~/.ssh

Generate the SSH key pair on your local machine (not the VPS):

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

Copy the public key to the VPS:

ssh-copy-id -i ~/.ssh/vps_deploy.pub deploy@YOUR_VPS_IP

The private key (~/.ssh/vps_deploy) will go into GitHub Secrets in the next step.

Step 2: Configure GitHub Secrets

Open your repository on GitHub. Go to Settings → Secrets and variables → Actions and add these repository secrets:

Secret Name Value
VPS_HOST Your VPS IP address or hostname
VPS_USER deploy
VPS_SSH_KEY Contents of ~/.ssh/vps_deploy (the private key)
VPS_PORT 22 (or your custom SSH port)
BOT_TOKEN Your bot platform token (Discord, Telegram, etc.)
LLM_API_KEY Your LLM provider API key
Tip
Use a non-standard SSH port (e.g., 2222) and disable password auth on the VPS. Add VPS_PORT to your secrets to keep it configurable.

Step 3: Write the Dockerfile

Create a Dockerfile in your repository root. This example uses a Python bot, but the pattern applies to any runtime.

FROM python:3.12-slim AS builder

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

FROM python:3.12-slim

WORKDIR /app
COPY --from=builder /install /usr/local
COPY . .

HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD python -c "import requests; requests.get('http://localhost:8080/health')" || exit 1

CMD ["python", "bot.py"]

Multi-stage builds keep the final image small. The HEALTHCHECK directive lets Docker (and your deploy workflow) know when the bot is actually ready.

Add a docker-compose.yml alongside it:

services:
  bot:
    build: .
    container_name: ai-bot
    restart: unless-stopped
    env_file: .env
    ports:
      - "8080:8080"
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

Step 4: Create the GitHub Actions Workflow

Create .github/workflows/deploy.yml:

name: Deploy AI Bot to VPS

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Copy files to VPS
        uses: appleboy/scp-action@v0.1.7
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          port: ${{ secrets.VPS_PORT }}
          source: "."
          target: "/home/deploy/ai-bot"
          overwrite: true

      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          port: ${{ secrets.VPS_PORT }}
          script: |
            cd /home/deploy/ai-bot

            # Write environment variables
            cat > .env << 'ENVEOF'
            BOT_TOKEN=${{ secrets.BOT_TOKEN }}
            LLM_API_KEY=${{ secrets.LLM_API_KEY }}
            ENVEOF

            # Build and deploy with zero downtime
            docker compose build --no-cache
            docker compose up -d --force-recreate --wait

            # Verify the container is healthy
            sleep 5
            if docker compose ps | grep -q "unhealthy\|Exit"; then
              echo "::error::Container is unhealthy, rolling back"
              docker compose logs --tail=50
              exit 1
            fi

            # Prune old images
            docker image prune -f

The --wait flag on docker compose up blocks until the health check passes. If the container fails, the workflow exits with an error and you get a GitHub notification.

Step 5: Add Automatic Rollback

For production bots, extend the workflow with a rollback mechanism. Before building the new image, tag the current one as a fallback:

      - name: Deploy with rollback
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          port: ${{ secrets.VPS_PORT }}
          script: |
            cd /home/deploy/ai-bot

            # Tag current image as rollback target
            docker tag ai-bot:latest ai-bot:rollback 2>/dev/null || true

            cat > .env << 'ENVEOF'
            BOT_TOKEN=${{ secrets.BOT_TOKEN }}
            LLM_API_KEY=${{ secrets.LLM_API_KEY }}
            ENVEOF

            docker compose build --no-cache
            docker compose up -d --force-recreate --wait

            # Health check with rollback
            sleep 10
            if ! docker compose ps --format json | python3 -c "
            import sys, json
            data = json.loads(sys.stdin.read())
            containers = data if isinstance(data, list) else [data]
            sys.exit(0 if all(c.get('Health','') == 'healthy' or c.get('State','') == 'running' for c in containers) else 1)
            "; then
              echo "Deploy failed — rolling back"
              docker compose down
              docker tag ai-bot:rollback ai-bot:latest
              docker compose up -d
              exit 1
            fi

            echo "Deploy successful"
            docker image prune -f

Step 6: Verify the Deployment

Push a commit to main and watch the Actions tab in GitHub. The workflow should complete in 1-3 minutes depending on image build time.

~/ai-bot

$ git push origin main
Enumerating objects: 12, done.
remote: Resolving deltas: 100% (4/4)
 To github.com:user/ai-bot.git
   a1b2c3d..e4f5g6h  main -> main

After the workflow finishes, verify on the VPS:

ssh deploy@YOUR_VPS_IP

# Check container status
docker compose -f /home/deploy/ai-bot/docker-compose.yml ps

# Check logs
docker compose -f /home/deploy/ai-bot/docker-compose.yml logs --tail=20

# Test health endpoint
curl http://localhost:8080/health

Troubleshooting

“Permission denied (publickey)” — The SSH key in VPS_SSH_KEY doesn’t match what’s in ~/.ssh/authorized_keys on the VPS. Re-copy the public key with ssh-copy-id. Make sure the secret contains the full private key including -----BEGIN and -----END lines.

Container starts then immediately exits — Check logs with docker compose logs. Common causes: missing environment variables (the .env file wasn’t written), invalid API keys, or the bot crashes on startup. Test locally first with docker compose up and a local .env file.

Workflow hangs on SSH step — Your VPS firewall is blocking the SSH port. Check ufw status or your cloud provider’s firewall rules. GitHub Actions runners connect from a range of IPs — you can’t whitelist them easily. Use a non-standard port instead of IP restrictions.

Danger
If your workflow writes .env to the server, ensure .env is in your .gitignore and the file permissions are 600. A world-readable .env on a shared server leaks your API keys.

Security Hardening

A few additions that take five minutes and prevent common attack vectors:

Restrict the deploy user. The deploy user should only have Docker permissions, nothing else. Don’t add them to sudo.

Use SSH certificate authentication instead of raw key pairs for larger teams. For solo projects, ed25519 keys are fine.

Pin action versions in your workflow. Use appleboy/ssh-action@v1.0.3 (a tag), not @master. A compromised action at @master has access to all your secrets.

Rate-limit your bot. AI bots that call LLM APIs can rack up costs fast if someone spams them. Add rate limiting at the application level before deploying to production.

# Add to docker-compose.yml for resource limits
services:
  bot:
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: "0.5"
Takeaway

Pin your action versions, restrict the deploy user’s permissions, and always rate-limit AI bots that call paid APIs.

Going Further

Once the basic pipeline works, consider these upgrades:

  • Add a staging branch. Deploy staging to a separate container on the same VPS for pre-production testing.
  • Use GitHub Container Registry. Build and push images to ghcr.io in one job, then pull on the VPS in a second job. This separates build from deploy and caches layers.
  • Add Watchtower. For simpler setups, Watchtower can poll your registry and auto-update containers without a CI pipeline.
  • Monitor with uptime checks. Use a free uptime monitor (UptimeRobot, Healthchecks.io) to ping your bot’s health endpoint and alert you if it goes down.

FAQ

Can I deploy without Docker?
Yes. Replace the Docker commands in the workflow with direct process management — use systemd to run your bot as a service. The SSH-based deployment pattern stays the same: copy files, install dependencies, restart the service. Docker just makes rollbacks and environment isolation simpler.
How do I handle database migrations during deployment?
Add a migration step in the SSH script before docker compose up. Run docker compose run --rm bot python manage.py migrate (or your framework’s equivalent). If the migration fails, the workflow exits before restarting the container, so the old version keeps running.
Is it safe to store SSH private keys in GitHub Secrets?
GitHub Secrets are encrypted at rest and only exposed to workflows — they’re never logged or visible in pull request workflows from forks. For most projects this is sufficient. For higher security requirements, use GitHub’s OIDC tokens with a secrets manager like HashiCorp Vault instead of static SSH keys.
How do I deploy to multiple VPS instances?
Use a matrix strategy in GitHub Actions. Define your servers as a matrix variable and the workflow runs the deploy job in parallel for each one. Alternatively, put a load balancer in front and do rolling deploys — update one server at a time.
Why not use a PaaS like Railway or Fly.io instead?
PaaS platforms are simpler but more expensive for long-running bots. A bot running 24/7 on Railway costs $5-20/month depending on usage, while a VPS gives you a fixed cost with more resources. VPS also gives you full control over networking, storage, and the ability to run multiple bots on one server.
How do I update environment variables without redeploying code?
Update the GitHub Secret, then manually trigger the workflow (add workflow_dispatch to the on: block). The workflow rewrites .env on every run, so the new value takes effect. For truly dynamic config, use a secrets manager or a config endpoint your bot polls.
What if my bot doesn’t have a health endpoint?
Add a minimal one. In Python: a background thread running a Flask/FastAPI server on port 8080 with a /health route returning 200. In Node.js: a basic HTTP server. If you can’t modify the bot, change the Docker HEALTHCHECK to check if the process is running instead: CMD pgrep -f bot.py || exit 1.
Can I deploy without Docker?
Yes. Replace the Docker commands in the workflow with direct process management — use systemd to run your bot as a service. The SSH-based deployment pattern stays the same: copy files, install dependencies, restart the service. Docker just makes rollbacks and environment isolation simpler.
How do I handle database migrations during deployment?
Add a migration step in the SSH script before docker compose up. Run docker compose run –rm bot python manage.py migrate (or your framework’s equivalent). If the migration fails, the workflow exits before restarting the container, so the old version keeps running.
Is it safe to store SSH private keys in GitHub Secrets?
GitHub Secrets are encrypted at rest and only exposed to workflows — they’re never logged or visible in pull request workflows from forks. For most projects this is sufficient. For higher security requirements, use GitHub’s OIDC tokens with a secrets manager like HashiCorp Vault instead of static SSH keys.
How do I deploy to multiple VPS instances?
Use a matrix strategy in GitHub Actions. Define your servers as a matrix variable and the workflow runs the deploy job in parallel for each one. Alternatively, put a load balancer in front and do rolling deploys — update one server at a time.
Why not use a PaaS like Railway or Fly.io instead?
PaaS platforms are simpler but more expensive for long-running bots. A bot running 24/7 on Railway costs $5-20/month depending on usage, while a VPS gives you a fixed cost with more resources. VPS also gives you full control over networking, storage, and the ability to run multiple bots on one server.
How do I update environment variables without redeploying code?
Update the GitHub Secret, then manually trigger the workflow (add workflow_dispatch to the on: block). The workflow rewrites .env on every run, so the new value takes effect. For truly dynamic config, use a secrets manager or a config endpoint your bot polls.
What if my bot doesn’t have a health endpoint?
Add a minimal one. In Python: a background thread running a Flask/FastAPI server on port 8080 with a /health route returning 200. In Node.js: a basic HTTP server. If you can’t modify the bot, change the Docker HEALTHCHECK to check if the process is running instead: CMD pgrep -f bot.py || exit 1.