← back to blog
2025-01-05 open-source

Servforge: Building a Cross-Platform LAMP/LEMP Installer in Shell

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.