Lay the foundation, once, the same way every time.
Provision an Ubuntu server from GitHub Actions: pick a profile, press run, and only the modules you chose are set up.
Quick start · Profiles · Run it · Modules · Custom profile · Dry run · Security
msingi (m-SEEN-gee) is Swahili for "foundation". A server needs one before anything is built on it: a firewall, a web server, certificates, a database, and a user with the right tools. msingi lays all of that from a single GitHub Actions run, over SSH, with small shell scripts you can read in a minute each.
Actions → Msingi → Run workflow
server_host 203.0.113.10
target_user developer
setup_profile Full Development Server
Setting up a fresh server by hand means the same twenty commands, in a slightly different order, with one forgotten step every time. msingi writes the steps down once. Every script checks what is already there and skips it, so running the workflow again is always safe.
- 🧱 Modular. Nine small scripts, one job each. Run them all, or only the ones you pick.
- 🎛️ Four profiles. Full server, system services only, user tools only, or your own mix.
- ♻️ Idempotent. Re-run any time. Installed things are detected and skipped.
- 👤 Creates the user for you. Name a
target_user. If it doesn't exist, msingi creates it, and can give it passwordless sudo. - 🔒 Lockout-aware firewall. UFW is enabled only after the OpenSSH rule is confirmed.
- 🔍 Dry run. See what would happen before anything on the server changes.
- 🔑 Hands you the Git key. The generated SSH public key is printed in the run summary, ready to paste into GitHub or GitLab.
- 🧹 Cleans up after itself. The uploaded scripts and the runner's copy of the deploy key are removed at the end, even if a step fails.
- Fork this repository. You need your own copy to add Secrets and Variables.
- Add two Secrets:
SERVER_SSH_KEYandSUDO_ACCESS_USER. - Run it: Actions → Msingi → Run workflow, then choose a profile.
That is the whole setup. Variables are optional (see Configuration).
- An Ubuntu server (tested on 24.04).
- An existing user on it with passwordless sudo (
NOPASSWD:ALL), reachable with your deploy key. - The deploy key's public half in that user's
~/.ssh/authorized_keys.
Add these under Settings → Secrets and variables → Actions → Secrets.
| Secret | What it is |
|---|---|
SERVER_SSH_KEY |
The private key the runner uses to connect. Paste the full key, no passphrase. Its public key must be in authorized_keys of SUDO_ACCESS_USER. |
SUDO_ACCESS_USER |
An existing user with passwordless sudo, such as ubuntu, root, or a dedicated deploy user. Used for every system module. Only required when a system module will run. |
Pick one when you run the workflow.
| Profile | System modules | User modules | Best for |
|---|---|---|---|
| Full Development Server | OpenSSH/UFW, Packages, Nginx, Certbot, PostgreSQL | uv, nvm, repos dir, Git + SSH | A brand-new server, everything in one go |
| System Services Only | OpenSSH/UFW, Packages, Nginx, Certbot, PostgreSQL | none | Infrastructure without personal dev tools |
| User Tools Only | none | uv, nvm, repos dir, Git + SSH | Adding dev tools to a user, no sudo needed |
| Custom (use repository variables) | whatever SETUP_* says |
whatever SETUP_* says |
Fine-grained control, or adding one module later |
When you dispatch the workflow you are asked for:
| Input | Required | Default | What it does |
|---|---|---|---|
server_host |
yes | n/a | Server IP or hostname |
server_port |
no | 22 |
SSH port |
target_user |
yes | n/a | The user to configure. Created if missing. User modules run as this user. |
make_user_sudo |
no | false |
Give the target user passwordless sudo (new or existing) |
ssh_public_key |
no | n/a | Added to the target user's authorized_keys if not already there |
setup_profile |
yes | Full | One of the four profiles |
git_user_name |
no | n/a | Required when the Git + SSH module runs |
git_user_email |
no | n/a | Required when the Git + SSH module runs; must look like an email |
dry_run |
no | false |
Show what would happen, change nothing |
There is no "create user" switch. If target_user doesn't exist, it is created.
These run as SUDO_ACCESS_USER and need sudo.
| Module | Script | What it does | Variable |
|---|---|---|---|
| 🔒 OpenSSH/UFW | openssh-ufw.sh |
Allows OpenSSH in UFW, then enables UFW only once the rule is confirmed. Skips enabling if it can't confirm it. | SETUP_OPENSSH_UFW |
| 🛠️ Packages | packages.sh |
Installs git, curl, wget, snapd, SQLite, Pandoc, TeX (XeTeX), and build tools | SETUP_PACKAGES |
| 🌐 Nginx | nginx.sh |
Installs, enables and starts Nginx, opens Nginx Full in UFW, and writes the port-proxy.conf snippet |
SETUP_NGINX |
| 🔏 Certbot | certbot.sh |
Installs Certbot through snap (needs snapd, which Packages provides) |
SETUP_CERTBOT |
| 🐘 PostgreSQL | postgres.sh |
Installs PostgreSQL and contrib, then enables and starts the service | SETUP_POSTGRES |
These run as target_user, without sudo.
| Module | Script | What it does | Variable |
|---|---|---|---|
| 🐍 uv | uv.sh |
Installs uv, then a Python through it if none is there | SETUP_UV |
| 📗 nvm | nvm.sh |
Installs nvm (version from NVM_VERSION), then the latest Node.js, and updates npm |
SETUP_NVM |
| 📁 Repos dir | repos.sh |
Makes sure ~/repos exists |
SETUP_REPOS_DIR |
| 🔑 Git + SSH | git-ssh.sh |
Sets global git name and email, and creates ~/.ssh/id_ed25519 if absent |
SETUP_GIT_SSH |
msingi installs Nginx and a reusable snippet. It does not create a server block; that part is yours.
server {
listen 80;
server_name your.domain;
set $upstream_port 8080; # the port your app listens on
include /etc/nginx/snippets/port-proxy.conf;
}Then sudo nginx -t && sudo systemctl reload nginx.
msingi stops at the foundation on purpose. These are left to you:
| Module | Follow-up |
|---|---|
| Nginx | Add your server block (above) |
| Certbot | Once your domain and server block exist: sudo certbot certonly --nginx -d your.domain |
| PostgreSQL | Create roles and databases: sudo -iu postgres psql |
| Git + SSH | Copy the public key from the run summary into GitHub, GitLab or wherever you host code |
Set dry_run to true to see what a run would do. Steps print [DRY RUN] Would … lines instead of acting.
[DRY RUN] Would create user: developer
[DRY RUN] Would run apt-get update
[DRY RUN] Would setup OpenSSH and UFW
[DRY RUN] Would install necessary packages
[DRY RUN] Would setup Nginx
[DRY RUN] Would setup uv
[DRY RUN] Would setup Git and SSHWhat a dry run does and doesn't do:
- ✅ Connects over SSH and checks whether the target user exists.
- ✅ Copies the scripts to a temporary folder in
/tmpon the server (removed again at the end). - ❌ Doesn't create users, install packages, enable services or edit configuration.
Choose Custom (use repository variables) and the SETUP_* variables decide what runs. Each must be the literal string true or false. Anything unset counts as false.
Add them under Settings → Secrets and variables → Actions → Variables.
SETUP_NGINX=true
SETUP_NVM=true
Run the workflow with the Custom profile and only Nginx and nvm are touched. This is also how you add a module later: set just that one to true, and everything already installed stays as it is.
| Variable | Default | What it does |
|---|---|---|
NVM_VERSION |
v0.40.4 |
The nvm release tag the install script fetches |
SETUP_* |
false |
Module switches for the Custom profile |
checkout repo
│
▼
pick modules ◄── profile, or SETUP_* variables (Custom)
│
▼
validate ── Git name/email present? SUDO_ACCESS_USER set for system modules?
│
▼
SSH key + connection test ── as SUDO_ACCESS_USER, or target_user if no system modules
│
▼
create / update target user (+ sudo, + authorized key)
│
▼
upload scripts to /tmp/msingi-<UTC timestamp>/
│
▼
system modules (sudo) → user modules (target_user)
│
▼
fetch Git public key → run summary
│
▼
cleanup: remove remote scripts and the runner's key (always runs)
The workflow variables only decide whether a script runs. Each script then checks its own state: binary already installed, service already enabled, directory or key already present. Run it twice and the second run changes nothing.
| Problem | What to check |
|---|---|
| SSH connection fails | Host, port, key format (ssh -i key user@host), the server's firewall, and that it is up |
SUDO_ACCESS_USER error |
Add the secret, and make sure that user really has passwordless sudo |
| Git module error | Provide both git_user_name and git_user_email whenever the profile includes Git + SSH |
| UFW wasn't enabled | The OpenSSH rule couldn't be confirmed. Fix that and run again; it's a deliberate safeguard |
| Certbot says snapd is missing | Run Packages first (it installs snapd), or include it in your Custom profile |
- Passwordless sudo goes only to
SUDO_ACCESS_USERand, if you ask for it,target_user. Keep that list short. - Use a dedicated deploy key, and rotate it now and then.
- The generated Git private key never leaves the server. Only the public half appears in the run summary.
- Audit
~/.ssh/authorized_keysand skim run logs for surprises. - Give
SUDO_ACCESS_USERno more privilege than the modules need.
- Add
scripts/your-module.shin the same shape as the others: check first, exit early if satisfied, print clear status lines. - Add a
SETUP_YOUR_MODULEvariable and wire it into the "Determine modules" step. - Add a step to
.github/workflows/msingi.yamlwith the sameif:condition and dry-run guard.
.github/workflows/msingi.yaml the workflow
scripts/
openssh-ufw.sh packages.sh nginx.sh certbot.sh postgres.sh
uv.sh nvm.sh repos.sh git-ssh.sh
LICENSE
README.md
MIT © Treeolive Tech Ltd. Use it, change it, ship it; just keep the copyright notice.
"...but the people who know their God shall be strong and do exploits."
Daniel 11:32