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 —…
- 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.).
.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.
# 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 |
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.
.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"
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
stagingto a separate container on the same VPS for pre-production testing. - Use GitHub Container Registry. Build and push images to
ghcr.ioin 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?
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?
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?
How do I deploy to multiple VPS instances?
Why not use a PaaS like Railway or Fly.io instead?
How do I update environment variables without redeploying code?
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?
/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.