Every time I set up a new server for a PHP project, I found myself running the same 50+ commands. Install Nginx, configure PHP-FPM, set up MySQL, install Redis, configure virtual hosts, set permissions. It was repetitive and error-prone. So I built servforge — a single command to set up a complete LAMP or LEMP stack.
What Servforge Does
# One command to install everything
curl -fsSL https://raw.githubusercontent.com/ruban-s/servforge/main/install.sh | bash
# Interactive mode - pick your components
servforge install
# Non-interactive with flags
servforge install --web nginx --php 8.3 --db mysql --cache redis --mode native
Servforge supports:
- Ubuntu/Debian, RHEL/CentOS/Rocky/AlmaLinux/Fedora, and macOS
- Native mode — installs packages directly on the host
- Docker mode — generates a Docker Compose stack, nothing touches the host
Architecture
The project is pure shell script — no Python, no Ruby, no external dependencies beyond what ships with the OS:
servforge/
├── install.sh # Entry point
├── lib/
│ ├── detect.sh # OS/distro detection
│ ├── ui.sh # Interactive prompts and colors
│ ├── validator.sh # Input validation
│ └── logger.sh # Structured logging
├── modules/
│ ├── nginx/
│ │ ├── install.sh # Native install logic
│ │ ├── docker.sh # Docker config generation
│ │ └── config/ # Template configs
│ ├── php/
│ │ ├── install.sh
│ │ ├── docker.sh
│ │ └── config/
│ ├── mysql/
│ │ ├── install.sh
│ │ ├── docker.sh
│ │ └── config/
│ └── redis/
│ ├── install.sh
│ ├── docker.sh
│ └── config/
├── templates/
│ ├── docker-compose.yml.tpl
│ ├── nginx-vhost.conf.tpl
│ └── php-fpm-pool.conf.tpl
└── tests/
├── test_detect.sh
├── test_nginx.sh
└── test_docker.sh
Each module is self-contained. Adding a new service (like Elasticsearch) means adding a new folder under modules/ with install.sh and docker.sh.
Cross-Platform Detection
The trickiest part was making it work across Linux distros and macOS. Each has different package managers, different paths, different service managers:
detect_os() {
if [[ "$OSTYPE" == "darwin"* ]]; then
OS="macos"
PKG_MANAGER="brew"
SERVICE_CMD="brew services"
return
fi
if [ -f /etc/os-release ]; then
. /etc/os-release
case "$ID" in
ubuntu|debian|linuxmint)
OS="debian"
PKG_MANAGER="apt"
SERVICE_CMD="systemctl"
;;
centos|rhel|rocky|almalinux)
OS="rhel"
PKG_MANAGER="dnf"
SERVICE_CMD="systemctl"
# Fallback for older CentOS
command -v dnf &>/dev/null || PKG_MANAGER="yum"
;;
fedora)
OS="fedora"
PKG_MANAGER="dnf"
SERVICE_CMD="systemctl"
;;
*)
log_error "Unsupported distribution: $ID"
exit 1
;;
esac
fi
}
Docker Mode
Docker mode doesn’t install anything on the host. Instead, it generates a complete Docker Compose configuration:
generate_docker_compose() {
local web_service="$1"
local php_version="$2"
local db_service="$3"
local cache_service="$4"
local project_dir="$5"
mkdir -p "$project_dir/docker"
cat > "$project_dir/docker-compose.yml" << COMPOSE
version: '3.8'
services:
COMPOSE
# Append each service based on selections
if [ "$web_service" = "nginx" ]; then
generate_nginx_service >> "$project_dir/docker-compose.yml"
cp templates/nginx-vhost.conf.tpl "$project_dir/docker/nginx.conf"
fi
if [ -n "$php_version" ]; then
generate_php_service "$php_version" >> "$project_dir/docker-compose.yml"
generate_php_dockerfile "$php_version" > "$project_dir/docker/Dockerfile.php"
fi
if [ "$db_service" = "mysql" ]; then
generate_mysql_service >> "$project_dir/docker-compose.yml"
fi
if [ "$cache_service" = "redis" ]; then
generate_redis_service >> "$project_dir/docker-compose.yml"
fi
# Add volumes and networks
append_docker_footer >> "$project_dir/docker-compose.yml"
log_success "Docker Compose stack generated in $project_dir/"
log_info "Run: cd $project_dir && docker compose up -d"
}
Configuration Management
Servforge stores its state in a config file so it knows what’s installed:
# ~/.servforge/config
INSTALL_MODE=native
WEB_SERVER=nginx
PHP_VERSION=8.3
DATABASE=mysql
CACHE=redis
INSTALLED_AT=2026-02-15T10:30:00
This enables servforge update to know what to upgrade and servforge uninstall to cleanly remove everything it installed.
Managing the Stack
# Check status of all services
servforge status
# Output:
# ┌─────────┬──────────┬────────┐
# │ Service │ Status │ Port │
# ├─────────┼──────────┼────────┤
# │ nginx │ running │ 80 │
# │ php-fpm │ running │ 9000 │
# │ mysql │ running │ 3306 │
# │ redis │ running │ 6379 │
# └─────────┴──────────┴────────┘
# Update all services
servforge update
# Uninstall cleanly
servforge uninstall
Testing
Shell scripts are notoriously hard to test. I use a lightweight testing approach:
#!/bin/bash
# tests/test_detect.sh
source lib/detect.sh
test_ubuntu_detection() {
# Mock /etc/os-release
export ID="ubuntu"
export VERSION_ID="22.04"
detect_os
assert_equals "$OS" "debian"
assert_equals "$PKG_MANAGER" "apt"
assert_equals "$SERVICE_CMD" "systemctl"
}
test_macos_detection() {
OSTYPE="darwin22"
detect_os
assert_equals "$OS" "macos"
assert_equals "$PKG_MANAGER" "brew"
}
# Run tests
run_tests test_ubuntu_detection test_macos_detection
Why Shell and Not Python/Go?
I considered Python and Go, but shell has one massive advantage: zero dependencies. Every server has bash. You don’t need to install Python 3.x or download a Go binary before you can set up your server. The whole point of servforge is to work on a fresh server with nothing installed.
The trade-off is that shell scripting is harder to maintain and test. But for a tool that runs once (during server setup) and then occasionally for updates, the simplicity of curl | bash is worth it.
What’s Next
I’m working on:
- Let’s Encrypt integration — auto-configure SSL with certbot
- Multiple PHP versions — run PHP 8.1 and 8.3 side by side
- Backup module — automated database and file backups to S3
Check out servforge on GitHub.