I've long done work with single dev workflows, where the CI-machine has been my laptop. That's perfectly fine for small software projects and stuff that does not require that much collaboration. Now I've hit a limit on that, because agents have become my team, so I needed a better solution.
Kalle Tolonen
Sept. 6, 2026
Create a dedicated user for deployment
# 1. Create user with disabled password and no interactive prompts
sudo adduser --disabled-password --gecos "" my_user
# 2. Generate key directly inside ~/.ssh/ as my_user
sudo -u my_user ssh-keygen -t ed25519 -C "github-actions-deploy" -f /home/my_user/.ssh/deploy_ci_key -N ""
# 3. Append the public key with security restrictions to authorized_keys
sudo -u my_user sh -c 'printf "restrict,no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty " >> /home/my_user/.ssh/authorized_keys && cat /home/my_user/.ssh/deploy_ci_key.pub >> /home/my_user/.ssh/authorized_keys'
# 4. Set strict SSH permissions and ownership
sudo chmod 700 /home/my_user/.ssh
sudo chmod 600 /home/my_user/.ssh/authorized_keys
sudo chown -R my_user:my_user /home/my_user/.ssh
# 5. Output the private key (to copy into GitHub Secrets as SSH_PRIVATE_KEY)
sudo cat /home/my_user/.ssh/deploy_ci_key
# 6. Shred the raw key files from the server
sudo shred -u /home/my_user/.ssh/deploy_ci_key /home/my_user/.ssh/deploy_ci_key.pub
# 7. Grant ownership of the web deployment directory to my_user
sudo mkdir -p /path/to/your/web/root
sudo chown -R my_user:my_user /path/to/your/web/root
# 8. Grab your server's ssh-fingerprint (and save it to Github's secrets as SSH_KNOWN_HOSTS) from a Trusted machine, preferably server itself
ssh-keyscan -H your_server_ip_or_domain
Flags explained for auth keys:
| Option | What it does |
|---|---|
restrict |
Master switch. Enables a broad set of modern restrictions (including the ones listed after it) and disables things like Unix domain socket forwarding. |
no-port-forwarding |
Blocks SSH port forwarding (-L, -R, -D). Prevents the key from being used to tunnel traffic through the server. |
no-X11-forwarding |
Disables X11 graphical forwarding. Harmless on most servers, but good to block. |
no-agent-forwarding |
Prevents SSH agent forwarding. Stops the key from being used to authenticate to other machines. |
no-pty |
Prevents allocation of a pseudo-terminal. Makes interactive shells much harder to obtain. |
Github can act as a hub for storing sensitive info, so lets outsource that to them. You can access them from the url:
https://github.com/your_github_user/your_repo/settings/secrets/actions
Settings
Actions
Variables added
name: Deploy
on:
push:
branches: [ main ]
concurrency:
group: production-deploy
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
echo "${{ secrets.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- name: Run deploy target
env:
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_DIR: ${{ secrets.DEPLOY_DIR }}
run: make deploy
The actual magic happens via a Makefile, so here's an example for that:
.PHONY: deploy deploy-dry run help
# Automatically load .env if it exists
-include .env
# Deployment parameters (defaults if not specified in .env)
DEPLOY_USER ?= your_user
DEPLOY_HOST ?= your_server
DEPLOY_DIR ?= /path/to/your/web/root
help: ## Show help
@echo "Build & deployment tasks:"
@echo " make deploy - Rsync static web app to $(DEPLOY_USER)@$(DEPLOY_HOST)"
@echo " make deploy-dry - Dry-run rsync deployment without transferring files"
@echo " make run - Open web/index.html locally"
deploy: ## Deploy static web app via rsync
@echo "Ensuring remote directory $(DEPLOY_DIR) exists on $(DEPLOY_HOST)..."
ssh $(DEPLOY_USER)@$(DEPLOY_HOST) "mkdir -p '$(DEPLOY_DIR)'"
@echo "Syncing web/ to $(DEPLOY_USER)@$(DEPLOY_HOST):$(DEPLOY_DIR)..."
rsync -avz --progress --delete web/ $(DEPLOY_USER)@$(DEPLOY_HOST):$(DEPLOY_DIR)
@echo "⚡ Deployment complete!"
deploy-dry: ## Dry-run rsync deployment to preview changes
rsync -avzn --progress --delete web/ $(DEPLOY_USER)@$(DEPLOY_HOST):$(DEPLOY_DIR)
run: ## Open locally in default browser
open web/index.html
The private key in GitHub Secrets is highly sensitive and should be rotated if ever exposed. The example uses a simple static webpage, so we'll be fine with such a makefile. For more complex workflows, we should obviously need testing and a more robust workflow.
I'm not advising you to leave SSH port open to the public for anything that matters, so adding a VPN is a small addition, but provides an extra layer of security by having the attacker need no penetrate the VPN before accessing via a stolen ssh-key or zero day attack on ssh itself.
No published comments yet.
Your comment may be published.