Skip to content

Instantly share code, notes, and snippets.

@aldy505
Last active March 27, 2025 18:08
Show Gist options
  • Save aldy505/7b22a8fb792dcc7ae42a963c3192712c to your computer and use it in GitHub Desktop.
Save aldy505/7b22a8fb792dcc7ae42a963c3192712c to your computer and use it in GitHub Desktop.
Automated deployment with systemd on remote server

Automated deployment with systemd on remote server

For any programming language, but this time, we'll do C# (ASP.NET Core backend application). I'm using GitHub Actions with no plugins or some fancy third party dependencies whatsoever, hopefully you can adapt this into your CI runner of choice.

The GitHub Actions file and the bash script file should be easily understood.

If you don't know what a certain command does, you should Google it.

Important

I would advise you to NOT use a public IP address, and you shouldn't open your port 22 publicly. Please use some service such as Zerotier or Tailscale to create a secure private network, and you can use that private IP address to connect to your remote server.

A few secrets to be set for the CI runner:

  • SSH_KEY: Containing the SSH key to your destination server
  • SSH_KEY_TYPE: When you're creating your SSH key, usually you're asked what key type do you want to use. Fill this with id_ed25519, id_rsa or even id_ecdh (if it still alive).
  • SSH_KNOWN_HOSTS: Your server signature. Execute ssh-keyscan -H YOUR-IP-ADDRESS, copy the whole result here.
  • SSH_USER: The user of your remote server
  • SSH_IP: Your server's IP, duh?
  • SSH_SUDO_PASSWORD: The password you use when you're using sudo .. on the server.
  • APPLICATION_SECRETS: The exact content of appsettings.json (basically your application's configuration file) -- although I don't really recommend this, you should store your application's secrets properly at a remote secret server, something like Hashicorp Vault.

For any other questions, feel free to reply to this gist, or email me to [email protected].

name: Master
on:
push:
branches:
- master
- main
workflow_dispatch:
jobs:
ci:
name: CI
runs-on: "ubuntu-latest" # Or probably if you have your runner as a self-hosted instance, ["self-hosted", "Linux"]
container: mcr.microsoft.com/dotnet/sdk:8.0 # Run the job inside a Docker container, if you're a C# developer, you must know what that is
timeout-minutes: 60
services:
database:
image: postgres:16
env:
POSTGRES_PASSWORD: password
POSTGRES_USER: postgres
POSTGRES_DB: database
options: >-
--hostname database.internal
--health-cmd "pg_isready"
--health-interval 15s
--health-timeout 10s
--health-retries 5
--health-start-period 30s
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PostgreSQL Client
run: |
apt-get update
apt-get install -y postgresql-client
env:
DEBIAN_FRONTEND: noninteractive
- name: Migrate PostgreSQL tables
run: |
for entry in "YourProject.Migrations/Tables"/*
do
psql -h database.internal -U postgres -d database --no-password -a -f "$entry"
done
env:
PGPASSWORD: password
PGSSLMODE: disable
- name: Fill PostgreSQL with data
run: |
for entry in "YourProject.Migrations/Data"/*
do
psql -h database.internal -U postgres -d database --no-password -a -f "$entry"
done
env:
PGPASSWORD: password
PGSSLMODE: disable
- name: Build Backend
run: dotnet build
cd:
name: CD
runs-on: "ubuntu-latest" # Or, again, if you're on self-hosted, you'd probably use: ["self-hosted", "Linux"]
container: mcr.microsoft.com/dotnet/sdk:8.0
timeout-minutes: 60
needs:
- ci # The `ci` job above needs to successfully complete first
steps:
# If you're using Zerotier or Tailscale or equivalent to connect to your server, you can use their GitHub Actions script now.
# (THIS IS THE RECOMMENDED WAY TO CONNECT TO YOUR SERVER, IF YOU DON'T HAVE DIRECT PRIVATE NETWORK ACCESS)
#
# For Zerotier: (see https://github.com/zerotier/github-action)
# - name: ZeroTier
# uses: zerotier/[email protected]
# with:
# network_id: ${{ secrets.ZEROTIER_NETWORK_ID }}
# auth_token: ${{ secrets.ZEROTIER_CENTRAL_TOKEN }}
#
# For Tailscale: (see https://github.com/tailscale/github-action)
# - name: Tailscale
# uses: tailscale/github-action@v3
# with:
# oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
# oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
# tags: tag:ci
- name: Checkout code
uses: actions/checkout@v4
- name: Install required packages
run: >
apt-get update &&
apt-get upgrade -y &&
apt-get install -y tar gzip curl ssh openssh-client
- name: Register SSH key
uses: shimataro/ssh-key-action@v2
with:
key: ${{ secrets.SSH_KEY }} # Your SSH private key
name: ${{ secrets.SSH_KEY_TYPE }} # This should be something like "id_ed25519" or "id_rsa"
known_hosts: ${{ secrets.SSH_KNOWN_HOSTS }} # Execute locally with `ssh-keyscan -H YOUR.IP.ADDRESS` (example: `ssh-keyscan -H 10.100.12.30`)
if_key_exists: replace
# Or for native scripting using bash:
# mkdir -p /$(whoami)/.ssh
# echo ${{ secrets.SSH_KEY }} >> /$(whoami)/.ssh/${{ secrets.SSH_KEY_TYPE }}
# echo ${{ secrets.SSH_KNOWN_HOSTS }} >> /$(whoami)/.ssh/known_hosts
# chmod 700 /$(whoami)/.ssh
# chmod 400 /$(whoami)/.ssh/${{ secrets.SSH_KEY_TYPE }}
- name: Build Backend
run: dotnet publish -c Release
- name: Copy to server
run: |
tar cfz YourProject.tar.gz -C ./YourProject/bin/Release/net8.0/ .
scp -rp -i /$(whoami)/.ssh/${{ secrets.SSH_KEY_TYPE }} YourProject.tar.gz ${{ secrets.SSH_USER }}@${{ secrets.SSH_IP }}:YourProject.tar.gz
- name: Stop existing service
run: |
echo ${{ secrets.SSH_SUDO_PASSWORD }} | ssh -tt -i /$(whoami)/.ssh/${{ secrets.SSH_KEY_TYPE }} ${{ secrets.SSH_USER }}@${{ secrets.SSH_IP }} "sudo systemctl stop your-project"
- name: Execute prepare script on remote machine
run: |
scp -rp -i /$(whoami)/.ssh/${{ secrets.SSH_KEY_TYPE }} setup-project-service.sh ${{ secrets.SSH_USER }}@${{ secrets.SSH_IP }}:setup-project-service.sh
scp -rp -i /$(whoami)/.ssh/${{ secrets.SSH_KEY_TYPE }} your-project.service ${{ secrets.SSH_USER }}@${{ secrets.SSH_IP }}:your-project.service
echo ${{ secrets.APPLICATION_SECRETS }} > appsettings.Production.json
scp -rp -i /$(whoami)/.ssh/${{ secrets.SSH_KEY_TYPE }} appsettings.Production.json ${{ secrets.SSH_USER }}@${{ secrets.SSH_IP }}:appsettings.Production.json
echo ${{ secrets.SSH_SUDO_PASSWORD }} | ssh -tt -i /$(whoami)/.ssh/${{ secrets.SSH_KEY_TYPE }} ${{ secrets.SSH_USER }}@${{ secrets.SSH_IP }} "sudo bash ./setup-project-service.sh production ${{ secrets.SSH_USER }}"
ssh -i /$(whoami)/.ssh/${{ secrets.SSH_KEY_TYPE }} ${{ secrets.SSH_USER }}@${{ secrets.SSH_IP }} 'rm setup-project-service.sh'
- name: Start service
run: |
echo ${{ secrets.SSH_SUDO_PASSWORD }} | ssh -tt -i /$(whoami)/.ssh/${{ secrets.SSH_KEY_TYPE }} ${{ secrets.SSH_USER }}@${{ secrets.SSH_IP }} "sudo systemctl start your-project"
#!/usr/bin/env bash
environment="$1"
ssh_user="$2"
# Preflight
if [[ -z "$environment" ]]; then
echo "The first parameter was not supplied, it should point to the environment"
exit 1
fi
if [[ -z "$ssh_user" ]]; then
echo "The second parameter was not supplied, it should point to the ssh user"
exit 2
fi
echo "Setup systemd service file"
service_file="/etc/systemd/system/your-project.service"
# Check if service file exists
if [[ -f "$service_file" ]]; then
# File exists
echo "$service_file exists, skipping..."
else
# File does not exists
echo "Service file does not exists, creating one..."
sed -i -e "s/__SSH_USER__/$ssh_user/g" your-project.service
sed -i -e "s/__ENV__/$environment/g" your-project.service
mv -v your-project.service "$service_file"
systemctl daemon-reload
systemctl enable your-service
echo "Service file bootstrapped"
fi
echo "Setup target directory"
if [[ -d "/home/$ssh_user/YourProject" ]]; then
# You might want to comment these out if you want to use the one from the server if it exists
echo "Directory exists, checking if YourProject/appsettings.Production.json matches appsettings.Production.json"
if [[ "$(sha256sum YourProject/appsettings.Production.json | awk '{print $1}')" = "$(sha256sum appsettings.Production.json | awk '{print $1}')" ]]; then
echo "appsettings.Production.json matches, not doing anything with it"
else
echo "appsettings.Production.json SHA256SUM is different, trusting the one from the server"
rm appsettings.Production.json
cp -v YourProject/appsettings.Production.json appsettings.Production.json
fi
rm -rf YourProject
mkdir YourProject
tar -zxf YourProject.tar.gz --directory YourProject
rm -f YourProject.tar.gz
mv -v appsettings.Production.json YourProject
chown -R "$ssh_user":"$ssh_user" YourProject
echo "Setup project service completed"
[Unit]
Description=YourProject
Documentation=https://github.com/your-username/YourProject
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=__SSH_USER__
Group=__SSH_USER__
ExecStart=/home/__SSH_USER__/YourProject/YourProject
ExecReload=/bin/kill -SIGHUP $MAINPID
Environment="ASPNETCORE_ENVIRONMENT=__ENV__"
TimeoutStartSec=20s
TimeoutStopSec=20s
Restart=on-failure
[Install]
WantedBy=multi-user.target
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment