Building a Bash Web Server from Scratch

Ever wondered if you could build a web server using just Bash? While Bash isn’t designed for high-performance web serving like Nginx or Apache, it’s a fantastic learning tool to understand how HTTP works under the hood. In this article, we’ll create a minimal Bash web server that handles basic GET requests, explores real-world use cases, and avoids common pitfalls.


Core Concepts

  1. HTTP Basics:
    • Requests start with GET / HTTP/1.1.
    • Responses include headers (Content-Type: text/html) and a body.
  2. TCP Sockets:
    • Bash uses /dev/tcp/$HOST/$PORT (a Bash feature, not a real file) to create sockets.
  3. Concurrency:
    • Our example handles one request at a time (simplistic; real servers use forking/threading).

Code Walkthrough

Here is a more robust version of the Bash web server script:

#!/bin/bash

# --- Configuration ---
PORT="${1:-8080}"  # Use the first argument as port, default to 8080
HOST="0.0.0.0"     # Listen on all interfaces
LOG_FILE="/tmp/bash_webserver.log"

# --- Response Templates ---
HTTP_200="HTTP/1.1 200 OK\r\n"
HTTP_404="HTTP/1.1 404 Not Found\r\n"
HTTP_405="HTTP/1.1 405 Method Not Allowed\r\n"
HTTP_500="HTTP/1.1 500 Internal Server Error\r\n"
CONTENT_TYPE_HTML="Content-Type: text/html\r\n"
CONTENT_TYPE_PLAIN="Content-Type: text/plain\r\n"
CONNECTION_CLOSE="Connection: close\r\n" # Indicate connection will close
RESPONSE_END="\r\n" # End of headers

# --- Logging Function ---
log() {
    local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
    echo "[$timestamp] $1" >> "$LOG_FILE"
    # Optionally echo to terminal as well
    echo "[$timestamp] $1"
}

# --- Function to Send Response ---
send_response() {
    local fd=$1
    local status_line=$2
    local content_type=$3
    local body=$4

    local body_len=${#body}
    # Construct headers
    local headers="${status_line}${content_type}Content-Length: ${body_len}\r\n${CONNECTION_CLOSE}${RESPONSE_END}"

    # Send headers and body
    printf "%b" "$headers$body" >&"$fd"
    log "Sent response: Status Line: '$status_line', Body Length: $body_len"
}

# --- Function to Handle Client Requests ---
handle_client() {
    local client_fd=$1
    local request_line=""
    local headers=""
    local line=""
    local method=""
    local path=""
    local protocol=""

    log "Handling new client connection on fd $client_fd"

    # --- Read the Request ---
    # Read the Request Line (e.g., "GET /index.html HTTP/1.1")
    if ! read -r request_line <&"$client_fd"; then
        log "Error: Failed to read request line from client fd $client_fd"
        # Send 500 error if we can't even read the first line
        send_response "$client_fd" "$HTTP_500" "$CONTENT_TYPE_PLAIN" "Failed to read request."
        return 1 # Indicate error/handling complete
    fi
    log "Received Request Line: $request_line"

    # Parse Request Line
    read -r method path protocol <<< "$request_line"
    if [[ -z "$method" || -z "$path" || -z "$protocol" ]]; then
        log "Error: Malformed request line: $request_line"
        send_response "$client_fd" "$HTTP_400" "$CONTENT_TYPE_PLAIN" "Bad Request: Malformed request line."
        return 1
    fi

    # --- Read Headers (simplified - read until empty line) ---
    while true; do
        if ! read -r line <&"$client_fd"; then
            log "Warning: Client disconnected while reading headers."
            return 1 # Treat as handled/disconnected
        fi
        # Break on empty line (end of headers)
        if [[ "$line" == $'\r' ]] || [[ -z "$line" ]]; then
             break
        fi
        headers+="$line"$'\n'
        # Optional: Limit header reading to prevent hanging
        # if [[ ${#headers} -gt 8192 ]]; then
        #     log "Error: Headers too large."
        #     send_response "$client_fd" "$HTTP_413" "$CONTENT_TYPE_PLAIN" "Request Entity Too Large."
        #     return 1
        # fi
    done
    log "Headers received (truncated): ${headers:0:100}..."

    # --- Validate Method ---
    if [[ "$method" != "GET" ]]; then
        log "Method Not Allowed: $method"
        send_response "$client_fd" "$HTTP_405" "$CONTENT_TYPE_PLAIN" "Method Not Allowed. Only GET is supported."
        return 1 # Handled
    fi

    # --- Route the Request ---
    local response_body=""
    local response_content_type="$CONTENT_TYPE_HTML"

    case "$path" in
        "/")
            response_body="<html><head><title>Bash Web Server</title></head><body><h1>Welcome to the Bash Web Server!</h1><p>This server is running entirely in Bash.</p><ul><li><a href='/about'>About</a></li><li><a href='/status'>Status</a></li></ul></body></html>"
            ;;
        "/about")
            response_body="<html><body><h1>About This Server</h1><p>This is a simple web server written in Bash to demonstrate HTTP basics.</p></body></html>"
            ;;
        "/status")
            # Example dynamic content
            local load_avg=$(uptime | awk -F'load average:' '{print $2}' | xargs)
            response_body="<html><body><h1>Server Status</h1><p>Load Average: ${load_avg:-N/A}</p><p>Current Time: $(date)</p></body></html>"
            ;;
        *)
            # 404 Not Found
            log "404 Not Found: $path"
            response_body="<html><body><h1>404 Not Found</h1><p>The requested resource '$path' was not found on this server.</p></body></html>"
            send_response "$client_fd" "$HTTP_404" "$CONTENT_TYPE_HTML" "$response_body"
            return 1 # Handled
            ;;
    esac

    # --- Send Successful Response ---
    if [[ -n "$response_body" ]]; then
        send_response "$client_fd" "$HTTP_200" "$response_content_type" "$response_body"
    else
        # This case shouldn't happen with current logic, but good practice
        log "Error: No response body generated for path $path"
        send_response "$client_fd" "$HTTP_500" "$CONTENT_TYPE_PLAIN" "Internal Server Error: No content generated."
    fi

    return 0 # Indicate successful handling (though connection closes)
}

# --- Main Server Loop ---
log "Starting Bash Web Server on $HOST:$PORT..."
log "Logging to: $LOG_FILE"

# Check if /dev/tcp is available (requires Bash built with networking)
if ! (exec 3<>/dev/tcp/localhost/$(shuf -i 10000-20000 -n 1) 2>/dev/null && exec 3<&- && exec 3>&-); then
    echo "Error: Bash /dev/tcp support is not available or networking is disabled."
    echo "Ensure Bash was compiled with networking support."
    exit 1
fi

# Attempt to bind to the port
if ! exec 4<>/dev/tcp/"$HOST"/"$PORT" 2>/dev/null; then
    echo "Error: Could not bind to $HOST:$PORT. Is the port in use or blocked?"
    log "Error: Failed to bind to $HOST:$PORT"
    exit 1
fi
log "Successfully bound to $HOST:$PORT"

# Close the initial binding descriptor, we'll accept new connections
exec 4<&-
exec 4>&-

while true; do
    # --- Accept a Connection ---
    # Re-open the listening socket for each connection (simplistic accept)
    # This is a common way to achieve a basic accept-like behavior in pure Bash
    # It's blocking and handles one connection at a time.
    if ! exec 4<>/dev/tcp/"$HOST"/"$PORT" 2>/dev/null; then
        log "Warning: Failed to re-open listening socket. Retrying..."
        sleep 1 # Brief pause before retrying
        continue
    fi

    # Handle the client connection in a subshell to prevent blocking the main loop
    # This allows handling the next connection while the current one is processed.
    # Note: This creates a new process for each connection.
    (
        # Inside the subshell, handle the client using fd 4
        handle_client 4
        # Close the client connection for this subshell
        exec 4<&-
        exec 4>&-
        log "Client connection handled and closed (in subshell)."
    ) & # Run the subshell in the background

    # Optional: Limit the number of background processes if needed
    # wait # Uncommenting this would make it sequential again
    # Or implement a simple process pool mechanism

done

# The server runs indefinitely. To stop it, use Ctrl+C.
# Cleanup trap could be added to close descriptors on exit, but the loop is infinite.

Explanation

  1. Configuration: Uses HOST and PORT variables, allowing the port to be passed as an argument (./server.sh 8081). Logs are written to a file.
  2. Logging Function: A dedicated log function makes it easier to track server activity and debug issues.
  3. Structured Response Sending: The send_response function centralizes the logic for constructing and sending HTTP responses, including calculating Content-Length.
  4. Improved Request Reading:
    • Reads the request line correctly.
    • Includes a basic loop to read headers until an empty line is found (though it doesn’t parse them deeply in this simple example). This prevents hanging on requests with bodies or many headers.
  5. Method Validation: Explicitly checks if the method is GET and returns a 405 Method Not Allowed for others.
  6. Routing: Uses a case statement to handle different paths (/, /about, /status) and generate appropriate responses. Includes a basic 404 handler.
  7. Dynamic Content: The /status route demonstrates fetching system information (uptime) and including it in the response.
  8. Concurrency Attempt: Runs handle_client in a background subshell (&). This is a basic attempt to handle multiple clients. Important Caveat: This approach has limitations. Each subshell is a separate process, consuming resources. The main loop immediately tries to re-open the listening socket, which might not be the standard way accept() works and could lead to issues under high load or with rapid successive connections. It’s a simplification for Bash. True concurrency in Bash is complex and resource-intensive.
  9. Error Checking: Checks for the availability of /dev/tcp and handles potential binding errors more gracefully.
  10. Standard Headers: Includes Content-Length and Connection: close headers, making the server more compliant with HTTP/1.1 expectations.

How to Run

  1. Save the code as bash_webserver.sh.
  2. Make it executable: chmod +x bash_webserver.sh.
  3. Run it: ./bash_webserver.sh (listens on port 8080) or ./bash_webserver.sh 8081 (listens on port 8081).
  4. Open a web browser or use curl (e.g., curl http://localhost:8080/) to test it.
  5. Check the log file (/tmp/bash_webserver.log) for server activity.

Remember the Limitations:

  • Performance: This is extremely slow and resource-intensive compared to real servers.
  • Concurrency: The subshell approach is basic and not suitable for high traffic.
  • Security: Lacks security features like input sanitization, protection against malicious requests, etc.
  • Features: Missing many HTTP features (POST requests, persistent connections, complex routing, file serving, etc.).
  • Robustness: Error handling is basic. Real servers handle many more edge cases.

This enhanced script provides a much better foundation for understanding the concepts involved in building a web server, even within the constraints of Bash.


Common Mistakes

  1. Not Handling Errors:
    • If the port is in use, Bash won’t warn you. Add error checking:
      if ! exec 3<>/dev/tcp/localhost/$PORT; then
          echo "Error: Port $PORT is unavailable."
          exit 1
      fi
  2. Blocking on read:
    • The server stops if a client sends a malformed request. Use timeout or non-blocking I/O (advanced).
  3. No Concurrency:
    • This server handles one request at a time. For multiple clients, use fork() (via & in Bash, but be careful with resource leaks).

Best Practices

  1. Use nc (netcat) for Simplicity:
    • Netcat is better suited for ad-hoc servers:
      echo -e "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n<h1>Hi!</h1>" | nc -l 8080
  2. Log Requests:
    • Save requests to a file for debugging:
      echo "$REQUEST" >> /var/log/bash_server.log
  3. Limit Response Size:
    • Avoid sending large files; Bash isn’t optimized for this.

Final Thoughts

While a Bash web server isn’t practical for production, it’s a fun way to learn HTTP and networking. For real projects, use:

Further Reading:


Happy coding! 🚀

bash web server http networking tcp sockets beginner tutorial