Skip to main content

Creating a Tailwind CSS manager with systemd user services

Learn how to build a bash script that manages multiple Tailwind CSS projects using systemd user services for reliable background processing.

General concept

Managing multiple Tailwind CSS projects can quickly become cumbersome. Each project requires its own watcher process that needs to run in the background, consuming terminal sessions or requiring complex window managers. This becomes especially challenging when working with multiple codebases or when you need your watchers to persist after logging out.

In this guide, we'll explore how to create a script that leverages systemd user services to manage Tailwind CSS processes. This approach provides several advantages over traditional terminal-based watchers, including automatic startup, persistent background operation, centralised logging, and graceful failure handling.

For those working on multiple Tailwind CSS projects simultaneously this can offer a solid solution. You should have basic familiarity with Linux command line tools and Tailwind CSS. By the end of this article, you'll have a practical tool that simplifies your development workflow and a better understanding of how to leverage systemd for development tasks.

Understanding the requirements

Before diving into the code, let's clarify what our Tailwind manager needs to accomplish:

  1. Add new Tailwind CSS projects to be managed
  2. Start and stop individual project watchers
  3. List all configured projects and their status
  4. Remove projects when no longer needed
  5. View logs for troubleshooting
  6. Check status of running services

Most importantly, our solution needs to maintain these watchers reliably in the background, survive terminal closures, and potentially restart automatically at login.

Setting up the basic structure

Our script, which we'll call twmanage (short for Tailwind Manager), will need to manage configuration storage and create the necessary systemd service files. We'll start by establishing the directory structure and configuration format:

#!/usr/bin/env bash
# Tailwind User Service Manager - Uses systemd user services

CONFIG_DIR="$HOME/.config/twmanage"
SERVICES_DIR="$HOME/.config/systemd/user"

# Create required directories
mkdir -p "$CONFIG_DIR"
mkdir -p "$SERVICES_DIR"

# Create config if it doesn't exist
if [ ! -f "$CONFIG_DIR/config.yaml" ]; then
  echo "projects: []" > "$CONFIG_DIR/config.yaml"
fi

This portion ensures we have the necessary directories for storing configuration and systemd service files. We're using YAML for configuration as it provides good readability while supporting hierarchical data. When the script first runs, it creates an empty projects list if the configuration doesn't exist.

Note

While YAML is being used for configuration storage, this is independent of your project's configuration. Your Tailwind CSS configuration files remain unchanged.

Adding new projects

The core functionality of our manager is the ability to add new Tailwind projects. This requires capturing project details and creating both a configuration entry and a systemd service file:

add_project() {
  local name="$1"
  local directory="$2"
  local input="$3"
  local output="$4"
  local args="${5:-}"

  # Add to config.yaml
  yq -i '.projects += [{"name": "'"$name"'", "directory": "'"$directory"'", "input": "'"$input"'", "output": "'"$output"'", "args": "'"$args"'"}]' "$CONFIG_DIR/config.yaml"

  # Create systemd service file
  cat > "$SERVICES_DIR/tailwind-$name.service" << EOF
[Unit]
Description=Tailwind CSS watcher for $name
After=network.target

[Service]
Type=simple
# Enable colour output
Environment="FORCE_COLOR=1"
WorkingDirectory=$directory
# Using the critical --watch=always flag for tailwindcss v4
ExecStart=/bin/bash -c 'tailwindcss -i $input -o $output --watch=always $args'
# Clean shutdown on service stop
KillSignal=SIGINT
Restart=on-failure
RestartSec=5s
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=default.target
EOF

  # Reload systemd
  systemctl --user daemon-reload

  echo "Project '$name' added successfully"
}

This function performs several important tasks:

First, it uses the yq tool (a YAML processor) to add a new project to our configuration file. This keeps all project metadata in one place for later reference.

Then, it creates a systemd service file with several noteworthy features:

  • The service is named based on the project name for easy identification
  • It sets the working directory to the project directory
  • It configures Tailwind CSS to watch for changes with the appropriate input and output files
  • It enables colour output for better log readability
  • It ensures proper shutdown by using SIGINT (equivalent to Ctrl+C)
  • It automatically restarts on failure after a 5-second delay
  • It directs all output to the systemd journal for centralised logging

Finally, it reloads the systemd configuration to recognize the new service file.

Managing project lifecycles

Once projects are added, we need functions to start, stop, and remove them. These functions interact with systemd's user service commands:

start_project() {
  local name="$1"
  systemctl --user start "tailwind-$name.service"
  systemctl --user enable "tailwind-$name.service"
  echo "Started Tailwind service for '$name'"
}

stop_project() {
  local name="$1"
  systemctl --user stop "tailwind-$name.service"
  systemctl --user disable "tailwind-$name.service"
  echo "Stopped Tailwind service for '$name'"
}

remove_project() {
  local name="$1"

  # Stop service if running
  systemctl --user stop "tailwind-$name.service" 2>/dev/null || true
  systemctl --user disable "tailwind-$name.service" 2>/dev/null || true

  # Remove service file
  rm -f "$SERVICES_DIR/tailwind-$name.service"

  # Remove from config
  yq -i 'del(.projects[] | select(.name == "'"$name"'"))' "$CONFIG_DIR/config.yaml"

  # Reload systemd
  systemctl --user daemon-reload

  echo "Project '$name' removed successfully"
}

The start function both starts the service immediately and enables it to start automatically at login. This ensures the service persists through restarts.

The stop function does the opposite, stopping the current service and disabling automatic startup.

The remove function combines several operations: stopping the service (if running), removing the service file, and removing the project from our configuration file. We use error suppression (2>/dev/null || true) to handle cases where the service was already stopped or not running.

Tip

If you're working with multiple terminal sessions, you can start your Tailwind services in a dedicated session at the beginning of your workday, then close that terminal and continue working in other windows. The background services will continue running.

Monitoring and information functions

To complete our tool, we need ways to view project status, list all configured projects, and access logs:

list_projects() {
  echo "Tailwind Projects:"
  echo "-----------------"
  printf "%-20s %-10s %-40s\n" "NAME" "STATUS" "DIRECTORY"
  echo "-----------------"

  yq -r '.projects[] | .name' "$CONFIG_DIR/config.yaml" 2>/dev/null | while read -r name; do
    status=$(systemctl --user is-active "tailwind-$name.service" 2>/dev/null || echo "INACTIVE")
    directory=$(yq -r '.projects[] | select(.name == "'"$name"'") | .directory' "$CONFIG_DIR/config.yaml")
    printf "%-20s %-10s %-40s\n" "$name" "$status" "$directory"
  done
}

view_logs() {
  local name="$1"
  journalctl --user -u "tailwind-$name.service" -f
}

show_status() {
  local name="$1"
  if [ -z "$name" ]; then
    echo "Checking all Tailwind services..."
    services=$(find "$SERVICES_DIR" -name "tailwind-*.service" -type f -exec basename {} \; 2>/dev/null)

    if [ -z "$services" ]; then
      echo "No Tailwind services found."
      return
    fi

    printf "%-25s %-10s %-40s\n" "SERVICE" "STATUS" "PROJECT"
    echo "----------------------------------------------------------------------"

    for service in $services; do
      project_name=${service#tailwind-}
      project_name=${project_name%.service}
      status=$(systemctl --user is-active "$service" 2>/dev/null || echo "inactive")
      printf "%-25s %-10s %-40s\n" "$service" "$status" "$project_name"
    done
  else
    echo "Service: tailwind-$name.service"
    status=$(systemctl --user is-active "tailwind-$name.service" 2>/dev/null || echo "inactive")
    echo "Status: $status"

    if [ "$status" = "active" ]; then
      echo "Service details:"
      systemctl --user status "tailwind-$name.service" --no-pager
    else
      echo "Service is not active"
    fi
  fi
}

The list_projects function reads our configuration file and queries systemd for the status of each service, presenting a formatted table of results.

The view_logs function leverages systemd's journalctl command to stream logs for a specific project, using the -f flag to follow new entries as they arrive.

The show_status function provides more detailed information about service status. It has two modes: when called without a project name, it shows a summary of all services; when called with a project name, it shows detailed status for that specific service.

Command parsing and main function

Finally, we need to wire up our functions to the command line interface:

case "$1" in
  add)
    shift
    add_project "$@"
    ;;
  start)
    start_project "$2"
    ;;
  stop)
    stop_project "$2"
    ;;
  list)
    list_projects
    ;;
  remove)
    remove_project "$2"
    ;;
  logs)
    view_logs "$2"
    ;;
  status)
    show_status "$2"
    ;;
  *)
    echo "Usage: $0 {add|start|stop|list|remove|logs|status} [arguments]"
    echo "  add NAME DIRECTORY INPUT OUTPUT [ARGS]  Add a new project"
    echo "  start NAME                             Start a project"
    echo "  stop NAME                              Stop a project"
    echo "  list                                   List all projects"
    echo "  remove NAME                            Remove a project"
    echo "  logs NAME                              View logs for a project"
    echo "  status [NAME]                          Show status of a project or all projects"
    ;;
esac

This case statement routes the first command line argument to the appropriate function and passes along any additional arguments. The default case provides helpful usage information.

Using the script in practice

Now that we understand how the script works, let's see it in action with a few practical examples:

Adding a new Tailwind project:

./twmanage add myproject ~/projects/myproject/src css/input.css css/output.css "--minify"

This command adds a project named "myproject" located in your home directory's projects folder, with input CSS from css/input.css and output to css/output.css, with the additional flag to minify the output.

Starting the project:

./twmanage start myproject

This command starts the Tailwind watcher for your project and enables it to start automatically at login.

Checking all projects:

./twmanage list

This displays a table of all your projects and their current status.

Viewing logs for troubleshooting:

./twmanage logs myproject

This shows a continuous stream of log output from your Tailwind process, which is helpful for diagnosing issues with your CSS.

Important

When you've modified your Tailwind configuration or need to force a full rebuild, you'll need to restart the service:
./twmanage stop myproject && ./twmanage start myproject

This ensures changes to your Tailwind configuration are applied properly.

Technical considerations and improvements

There are a few technical points worth mentioning:

The script uses systemd user services rather than system services, meaning they run in the user's session without requiring root privileges. This is the appropriate scope for development tools.

We're leveraging the yq command-line tool for YAML processing. This is a dependency that you'll need to install if you plan to use this script.

The --watch=always flag is specifically for Tailwind CSS v4, which aligns with our target technical stack. For other versions, you might need to adjust this flag.

Potential improvements to this script could include:

  • Adding validation for input parameters
  • Supporting more Tailwind configuration options
  • Adding a command to restart a service
  • Implementing a way to update existing project configurations

Conclusion

We've created a practical tool that simplifies managing multiple Tailwind CSS projects by leveraging systemd user services. This approach provides several advantages over traditional terminal-based solutions, including reliability, persistence, and centralised logging.

By using this script, you can reclaim terminal windows, ensure your CSS is always compiled, and have a more robust development environment. The techniques used here can also be applied to other development tasks that require long-running processes.

For your next steps, consider extending this script to match your specific workflow, or explore other development tasks that could benefit from systemd integration. You might also look into more advanced systemd features like socket activation or dependency management to further enhance your development environment.