Automated, modular, idempotent server setup via GitHub Actions for infrastructure tooling.
The workflow (.github/workflows/server-bootstrap.yaml) connects to your Linux server over SSH using a deployment private key, uploads the bash scripts in scripts/, and runs only the modules selected by a profile or by custom repository variables.
All scripts are idempotent: safe to re-run; they skip work when already satisfied.
- Fork this repository to your own GitHub account (required to add Secrets/Variables).
- Add repository Secrets:
SERVER_SSH_KEY,SUDO_ACCESS_USER. - (Optional) Add Variables:
NVM_VERSIONand/or anySETUP_*for the Custom profile. - Run the workflow: Actions β Server Bootstrap β choose a profile and inputs.
- Ubuntu server (tested on 24.04)
- Existing sudo user with passwordless sudo (for system modules)
- SSH access using the deployment key's public part
- Forked repository to add Secrets/Variables
Add these in Settings β Secrets and variables β Actions β Secrets tab. Click New repository secret.
Private SSH key used by the workflow runner to connect to the server. The corresponding public key must be present in ~/.ssh/authorized_keys for SUDO_ACCESS_USER (and any other target you connect as).
Setup:
- Paste full private key content (no passphrase recommended for automation).
- Ensure its public key is in
~/.ssh/authorized_keysof the sudo access user.
Existing user on the server with passwordless sudo (NOPASSWD:ALL). Used whenever system modules are executed (package installs, service management). Must be reachable via SERVER_SSH_KEY.
Example: ubuntu, root, or a dedicated deploy user with passwordless sudo.
Add these in Settings β Secrets and variables β Actions β Variables tab if you need overrides.
| Variable | Default | Description |
|---|---|---|
| NVM_VERSION | v0.40.4 | nvm tag used by install script |
Only evaluated when setup_profile == "Custom (use repository variables)". Each must be set to literal string "true" or "false" (GitHub stores them as strings).
System (run via SUDO_ACCESS_USER):
SETUP_OPENSSH_UFWSETUP_PACKAGESSETUP_NGINXSETUP_CERTBOTSETUP_POSTGRES
User (run as target_user):
SETUP_UVSETUP_NVMSETUP_REPOS_DIRSETUP_GIT_SSH
Select one profile when dispatching the workflow:
System modules: OpenSSH/UFW, Packages, Nginx, Certbot, PostgreSQL
User modules: uv, nvm, repos directory, Git + SSH key
Use case: Complete server bootstrap with all tools
System modules only (OpenSSH/UFW, Packages, Nginx, Certbot, PostgreSQL)
Use case: Infrastructure setup without user development tools
User modules only (uv, nvm, repos directory, Git + SSH key)
Use case: Add dev tools to existing user without system changes
Boolean repository variables (SETUP_*) decide which modules run.
Use case: Fine-grained control over individual modules
When dispatching the workflow, you'll be prompted for these inputs:
| Input | Required | Default | Description |
|---|---|---|---|
| server_host | Yes | - | Server IP or hostname |
| server_port | No | 22 | SSH port |
| target_user | Yes | - | The Linux user to configure on the server. This is the user who will have dev tools installed (uv, nvm, repos, Git/SSH). If the user doesn't exist, it will be created automatically. User modules run as this user. |
| make_user_sudo | No | false | Gives target user passwordless sudo if true |
| ssh_public_key | No | - | Added to target user's authorized_keys (optional) |
| setup_profile | Yes | - | One of the four profiles |
| git_user_name | No | - | Required if Git/SSH module runs |
| git_user_email | No | - | Required if Git/SSH module runs |
| dry_run | No | false | Dry run mode - simulates execution without making actual changes. Useful for validating configuration and logic before affecting the server. |
Note: There is no "create user" toggleβcreation is automatic if the user does not exist.
Script: openssh-ufw.sh
What it does:
- Adds
OpenSSHallow rule if absent - Only enables UFW after confirming the rule, mitigating lockout risk
- Skips enable if rule confirmation fails
Script: packages.sh
What it does:
- Installs build/development libraries: git, curl, toolchains, SQLite, Pandoc, TeX, snapd, etc.
Script: nginx.sh
What it does:
- Installs and starts Nginx service
- Creates
/etc/nginx/snippets/port-proxy.conf(expects$upstream_portvariable) - Does NOT create server blocksβyou must add your own site config
Example server block:
server {
listen 80;
server_name your.domain;
set $upstream_port 8080; # match your application port
include /etc/nginx/snippets/port-proxy.conf;
}Then run: sudo nginx -t && sudo systemctl reload nginx
Script: certbot.sh
What it does:
- Installs Certbot via snap
Manual follow-up: Run sudo certbot certonly --nginx -d your.domain after setting up domain + server block.
Script: postgres.sh
What it does:
- Installs PostgreSQL server + contrib
- Enables and starts service
- Does NOT create databases or rolesβmanual configuration required
Manual follow-up: Create roles/databases with sudo -iu postgres psql
Script: uv.sh
What it does:
- Installs uv Python package manager
- If Python missing, installs via
uv python install
Script: nvm.sh
What it does:
- Installs specified nvm version
- Installs latest Node.js
- Updates npm
Script: repos.sh
What it does:
- Ensures
~/reposdirectory exists
Script: git-ssh.sh
What it does:
- Sets global git config (overwrites previous name/email values)
- Generates
~/.ssh/id_ed25519key pair if absent - Public key surfaced in workflow summary for adding to hosting platforms
Requirements: git_user_name and git_user_email must be provided in workflow inputs.
| Variable | Script(s) | Notes |
|---|---|---|
| SETUP_OPENSSH_UFW | openssh-ufw.sh | Adds OpenSSH allow rule; enables UFW only if rule confirmed |
| SETUP_PACKAGES | packages.sh | Development toolchain & libs including snapd |
| SETUP_NGINX | nginx.sh | Installs, enables service; creates proxy snippet only |
| SETUP_CERTBOT | certbot.sh | Installs via snap (snapd provided by packages.sh) |
| SETUP_POSTGRES | postgres.sh | Enables & starts service |
| SETUP_UV | uv.sh | Installs uv + Python if missing |
| SETUP_NVM | nvm.sh | Installs Node + updates npm |
| SETUP_REPOS_DIR | repos.sh | Ensures ~/repos |
| SETUP_GIT_SSH | git-ssh.sh | Needs workflow inputs git_user_name/email |
- Repository checkout - Scripts become available locally on runner
- Module determination - Based on profile or custom variables
- Input validation - Checks Git info if needed; verifies SUDO_ACCESS_USER for system modules
- SSH key provisioning - Sets up runner authentication
- SSH connectivity test - As sudo access user or target user depending on module mix
- Target user creation/update - Automatically creates user if missing
- Script upload - Via
scpto/tmp/on remote server - Module execution - Conditionally runs each selected module
- Credential retrieval - Fetches SSH public key if Git/SSH module ran
- Summary output - Displays configuration details
- Cleanup - Removes remote scripts and local SSH key
All scripts are designed to be safe to re-run:
- Variables only control whether a script runs - Each script internally checks state
- Binary presence - Skips installation if already exists
- Service state - Only starts/enables if needed
- Directory/key existence - Creates only if absent
Re-running with "true" does not break prior setup. Safe to add modules later by re-running workflow with Custom profile and enabling only new modules.
- Add PostgreSQL later: Set
SETUP_POSTGRES=true(custom profile) β Only PostgreSQL script runs - Profile switch: Include previously omitted modules β Already installed modules detected and skipped
The workflow includes a dry run mode that simulates execution without making any actual changes to the server. This is useful for:
- Validation: Verify configuration before running against production/development servers
- Debugging: Test workflow logic without affecting server state
- Documentation: Generate execution plans for review
- Training: Understand what the workflow will do without risk
Set the dry_run input to true when dispatching the workflow. The workflow will:
- β Connect to the server via SSH
- β Check current state (users, installed packages, etc.)
- β Display what actions would be taken
- β Not create users, install packages, or modify any files
- β Not enable services or change configurations
All steps will output [DRY RUN] Would... messages instead of performing actual operations.
[DRY RUN] Would create user: developer
[DRY RUN] Would install necessary packages
[DRY RUN] Would setup Nginx
[DRY RUN] Would setup uv
Profile: Full Development Server
Inputs: Provide git_user_name/email
Outcome: Complete system + user environment with all tools
Profile: System Services Only
Outcome: Services installed; no user toolchain
Profile: User Tools Only
Outcome: uv, nvm, repos, Git/SSH installed; no system changes
Profile: Custom
Variables: Set specific SETUP_* variables (e.g., SETUP_POSTGRES=true)
Outcome: Only specified modules run; existing setup untouched
Profile: Custom
Variables: SETUP_NVM=true
Outcome: nvm added; prior modules untouched
Enable only Nginx + nvm:
SETUP_NGINX=true
SETUP_NVM=true
Then dispatch workflow with profile: Custom (use repository variables).
After successful setup, if Git/SSH module runs, the workflow fetches:
- SSH public key (
id_ed25519.pub) displayed in workflow summary for adding to hosting services (GitHub, GitLab, etc.)
The private key remains securely on the server at ~/.ssh/id_ed25519.
git_user_name and git_user_email must be provided if:
- Profile includes Git/SSH module (Full Development Server, User Tools Only)
- Custom profile sets
SETUP_GIT_SSH=true
| Issue | Solution |
|---|---|
| SSH connection fails | Verify host, port, key format (ssh -i key user@host), firewall rule, server reachable |
| Missing SUDO_ACCESS_USER error | Add secret; verify passwordless sudo exists |
| Certbot failure (snap not found) | This shouldn't happen anymoreβpackages.sh now installs snapd |
| Git module error (missing name/email) | Provide both inputs when profile includes Git/SSH |
| UFW enable skipped | Ensure OpenSSH rule added; rerun script |
Open an issue with:
- Workflow run URL
- Relevant log excerpts
- Server OS/version
- Module(s) failing
- Steps attempted
- Nginx: Create server block referencing snippets
- Certbot: Run
sudo certbot certonly --nginx -d your.domain(after domain + server block configured) - SSH Key: Add retrieved public key to Git hosting provider
- PostgreSQL: Create roles/databases (
sudo -iu postgres psql) - Security: Rotate generated SSH keys as needed
- Passwordless sudo is restricted to
SUDO_ACCESS_USERand optionallytarget_userifmake_user_sudo=true - Deployment SSH key should be dedicated; rotate periodically
- Generated Git SSH key remains on server (private part not exfiltrated)
- Limit the number of users with passwordless sudo
- Audit
~/.ssh/authorized_keysregularly - Review workflow run logs for unexpected warnings
- Limit
SUDO_ACCESS_USERprivileges to what is necessary - Rotate
SERVER_SSH_KEYregularly
To add new scripts:
- Create script under
scripts/following existing pattern:- Check existence first
- Exit early on success
- Echo status clearly
- Add corresponding variable in workflow logic (e.g.,
SETUP_YOUR_MODULE) - Add step in workflow YAML with conditional execution
.github/
workflows/
server-bootstrap.yaml
scripts/
certbot.sh
git-ssh.sh
nginx.sh
nvm.sh
openssh-ufw.sh
packages.sh
postgres.sh
repos.sh
uv.sh
LICENSE
README.md
TODO.md
Located in scripts/:
System (require sudo):
openssh-ufw.sh- Firewall configurationpackages.sh- Development packages + snapdnginx.sh- Web servercertbot.sh- SSL certificatespostgres.sh- Database server
User (non-sudo):
uv.sh- Python package managernvm.sh- Node version managerrepos.sh- Repos directorygit-ssh.sh- Git config + SSH key generation
MIT