Deploy via SSH to a VM
Deploy to a virtual machine via SSH — CI/CD foundations. Learn the core concept, hands-on steps, troubleshooting, and what to study next in this practical tutorial.
Focus: deploy to a virtual machine via ssh
You've got a pipeline that builds, tests, and packages your application perfectly. But then what? Too many teams stop at the artifact — leaving the final leap from CI to production as a manual, copy-paste ritual that takes down servers and burns weekends. Deploying to a virtual machine via SSH is the missing piece that turns a good pipeline into a true CI/CD system: your code goes from commit to running server with zero human hands on the machine. This lesson hands you a reliable, repeatable pattern for pushing artifacts and running commands on a remote VM directly from your pipeline.
The problem this lesson solves
Manual deployments are the silent killer of delivery velocity. Every time you SSH into a server to git pull or upload a tarball, you introduce risk: wrong branch, outdated files, forgotten service restarts, or a half-finished deployment when your laptop battery dies. According to 2024 DORA reports, teams with fully automated deployments deploy 200× more frequently and recover 5× faster than their low-performing peers. The bottleneck is rarely the code — it's the final mile: getting the verified artifact onto the production machine and activating it reliably.
Automating deployment via SSH also removes the 'works on my machine' excuse. Your pipeline becomes the only source of truth for what goes to the server. It ensures that every deployment is identical — same artifact, same commands, same order — and leaves an audit trail you can actually trace. Without this capability, your CD pipeline is just CI with extra steps, and your ops team is still the deployment tool.
Core concept / mental model
Think of your deployment pipeline as a delivery drone. The CI phase builds the package — the drone's cargo. The SSH deployment phase is the flight path: it carries cargo to the exact warehouse (your VM) and performs the landing sequence (placing files, starting services). The SSH protocol is the secure channel that makes this possible — like a guarded courier that verifies the delivery person's identity before handing over the goods.
Here's the mental model in action:
- Artifact — the result of your build (e.g., a
.tar.gz, a Docker image, or compiled binaries). - SSH connection — an encrypted tunnel between your CI runner and the target VM, authenticated via SSH keys (not passwords).
- Remote commands — the 'landing sequence':
scpto copy files,sshto run install/start scripts, and health checks to confirm the service is up.
A key distinction: you are NOT running your application on the CI runner. You're pushing the artifact over the network and telling the VM how to install and restart it — the VM does the heavy lifting of running the code.
How it works step by step
Let's walk through the full flow from code push to running service on a virtual machine. The pipeline does these steps in sequence:
1. Build and test your artifact
Your CI pipeline (e.g., GitHub Actions, GitLab CI, Jenkins) starts by checking out the code, installing dependencies, running tests, and producing a deployable artifact. This is the 'make the drone cargo' phase — you don't touch the server yet.
2. Prepare the SSH connection
Before the pipeline can talk to your VM, you need:
- A dedicated SSH key pair — generate a public/private key on the CI runner or use a pre-generated one stored as a secret.
- The public key installed on the VM's
~/.ssh/authorized_keysfor the deployment user. - The private key safely stored in your CI/CD platform's secrets (never in your repo).
- The VM's hostname/IP and username configured as environment variables or secrets.
3. Transfer the artifact via scp
The scp (secure copy) command uses SSH to copy files over an encrypted channel. The pipeline uploads the artifact to a known location on the VM, like /opt/myapp/releases/.
4. Run remote deployment commands via ssh
Next, the pipeline SSHes into the VM to run a deployment script. That script typically:
- Stops the current service (or puts it into maintenance mode).
- Backs up the old version (optional but wise).
- Extracts or installs the new artifact.
- Restarts the service (e.g., via
systemctl restartor a process manager). - Optionally runs migrations or cache clears.
5. Verify the deployment
Finally, the pipeline runs a health check over SSH — like curl http://localhost/health — and only marks the deployment as successful if the response is 200 OK. This catches bad deploys before users do.
Hands-on walkthrough
Let's put this into practice with a concrete example. We'll assume your CI runner is GitHub Actions, but the same commands work in any CI system.
Generate an SSH key pair
First, create a dedicated key pair for deployments. Do this locally once, then add the private key to your CI secrets:
ssh-keygen -t ed25519 -C "deploy-bot" -f deploy_key
# Output:
# Your identification has been saved in deploy_key
# Your public key has been saved in deploy_key.pub
Security note: Never reuse your personal SSH key for CI. Generate a dedicated low-privilege key that can only run specific commands (covered in troubleshooting).
Add the public key to your VM
ssh-copy-id -i deploy_key.pub deploy@your-vm-ip
# Or manually append to ~/.ssh/authorized_keys
cat deploy_key.pub >> ~/.ssh/authorized_keys
Now test it:
ssh -i deploy_key deploy@your-vm-ip 'echo hello'
# Output:
# hello
Write a deployment script
Create a script on the VM (or embed it in the CI step) that performs the actual deployment:
#!/bin/bash
# deploy.sh — runs on the VM
set -euo pipefail
APP_DIR="/opt/myapp"
RELEASE_DIR="$APP_DIR/releases/$(date +%Y%m%d%H%M%S)"
PARTNER_DIR="$APP_DIR/current"
mkdir -p "$RELEASE_DIR"
tar -xzf /tmp/app.tar.gz -C "$RELEASE_DIR"
# Swap the symlink to the new release
ln -sfn "$RELEASE_DIR" "$PARTNER_DIR"
# Restart service (adjust for your stack)
sudo systemctl restart myapp
# Health check — exit 1 if not healthy
curl -fsS http://localhost:8080/health || { echo "Health check failed!"; exit 1; }
echo "Deployment complete."
Wire it into your pipeline
Here's a GitHub Actions job that builds, SCps the artifact, and runs the remote script:
name: Deploy to VM
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build artifact
run: |
tar -czf app.tar.gz --exclude='.git' .
- name: Upload artifact to VM
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.VM_HOST }}
username: ${{ secrets.VM_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
source: "app.tar.gz"
target: "/tmp"
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VM_HOST }}
username: ${{ secrets.VM_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
chmod +x /tmp/deploy.sh
/tmp/deploy.sh
The job runs on every push to main, uploads the artifact, and executes the deployment script on the VM. Expected output in the CI logs includes Deployment complete. if everything works.
Compare options / when to choose what
SSH is not the only way to deploy to a VM. Here's a quick comparison:
| Method | Pros | Cons | Best for |
|---|---|---|---|
SSH (scp + ssh) |
Simple, no extra infra, works with any VM | Manual scripting, need to manage keys | Small to medium apps, quick wins |
| Ansible / Puppet | Declarative, idempotent, config management | Learning curve, extra tooling | Complex multi-server setups |
| Docker + SSH | Consistency, easy rollback | Requires Docker on VM, more moving parts | Containerized apps |
| Cloud deploy tools (EB, AWS CodeDeploy) | Managed, built-in rollbacks | Vendor lock-in, cost | Cloud-native teams |
For your first automation, SSH is the simplest and most universal — you already have the server, and there's no extra software to install. Choose Ansible if you have dozens of servers with different configs, or a CI-native deploy plugin if you use a PaaS.
Troubleshooting & edge cases
SSH deployments fail more often because of configuration mistakes than code bugs. Here are the usual suspects:
Permission denied (publickey)
The VM doesn't recognize your key. Check that the public key is in ~/.ssh/authorized_keys with correct permissions (.ssh 700, authorized_keys 600) and that the private key is not world-readable on the CI runner.
Host key verification failed
The VM's fingerprint isn't in the known_hosts file. In CI, set StrictHostKeyChecking=no in your SSH options, but do this only for trusted hosts — or add the host key to your CI secrets.
Connection timed out
The VM's firewall is blocking port 22. Open it, or use a different port and configure your SSH command accordingly.
Service crashes after deploy
Your deploy script started the service but it's not healthy. Common causes: missing environment variables, wrong path, or the artifact didn't include a required file. Always run the health check — don't assume success.
Pro tip: Use a deployment user with restricted privileges (only allowed to run the deploy script) instead of
root. For example, add a line likecommand="/opt/deploy/deploy.sh",no-pty,no-port-forwarding ssh-ed25519 AAA...inauthorized_keys— this limits what the CI key can do even if it leaks.
Another edge case: key rotation. If you regenerate keys, update both the VM's authorized_keys and your CI secrets — otherwise deployment silently breaks. Set up a reminder or automate key rotation as part of your security practice.
What you learned & what's next
You've now bridged the gap between CI and production. You understand the core idea behind deploying to a virtual machine via SSH: secure, scripted, and verifiable. You can generate a deploy key, install it on your VM, write a deployment script, and wire it into your pipeline — covering both practical exercise and conceptual understanding. This is a foundational pattern you'll reuse for every project that runs on a VM.
The next lesson in this track is Rollbacks and release management. You'll learn how to safely revert a bad deployment, keep multiple versions around, and use techniques like symlink switching and blue/green to minimize downtime. With SSH deploys in your toolkit, plus rollback skills, you'll be able to ship with confidence and recover fast — the hallmarks of a mature delivery pipeline.
Practice recap
Now it's your turn: set up a simple VM (or use a local Vagrant box) and deploy a small web app via SSH. Generate a dedicated key, write a deploy.sh that extracts an artifact and restarts a mock service, then push a commit to your GitHub repo and watch the pipeline do the work. Next, break something on purpose — change the artifact to a bad version — and confirm your health check catches it.
Common mistakes
- Hardcoding the SSH private key in the repository — anyone with repo access can deploy to your VM, or worse, get a key to your server. Always store keys as encrypted CI/CD secrets.
- Using the root user for SSH deployment — a single command failure can take down the whole machine. Create a dedicated deploy user with minimal privileges.
- Forgetting to run a health check after deployment — your service may crash silently and your pipeline reports success when users see 502 errors.
- Not setting a timeout on SSH commands — a hung
sshprocess can block your pipeline for minutes, causing CI timeouts and delayed feedback. - Ignoring host key verification — disabling
StrictHostKeyCheckingwithout understanding the risk exposes you to man-in-the-middle attacks on public networks.
Variations
- Use Ansible or other configuration management tools for declarative, idempotent deployment across many servers.
- Deploy a Docker image to the VM and run it with
docker compose— more consistent dependencies but requires container runtime on the server. - Use a CI-native plugin like the GitHub Actions
appleboy/ssh-actionor Bitbucket Pipelines SSH step — they abstract SSH connection management.
Real-world use cases
- A side project blog running on a cheap VPS — you push to main and the server auto-updates WordPress files via SSH.
- A SaaS backend on an AWS EC2 instance — your pipeline deploys a new API version weekly with a rolling restart and a health check.
- An on-premise reporting tool for internal data — your CI securely sends nightly updates to a locked-down VM with minimal user privileges.
Key takeaways
- SSH deployment is the bridge between CI and production — it moves verified artifacts to the server and runs the activation commands.
- Always use a dedicated SSH key pair for deployments, store the private key in CI secrets, and install the public key on your VM.
- Separate the actions:
scpthe artifact, thensshto run a script that stops, installs, restarts, and verifies the service. - A health check is non-negotiable — your deployment isn't done until your app responds correctly.
- Compare SSH with tools like Ansible or Docker when your infrastructure grows beyond a single VM.
- Troubleshoot with a methodical checklist: key permissions, host key, firewall, and service health.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.