Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 38 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,19 @@ Get the developer experience of Docker without the resource cost.
| **Isolation** | Heavy (Container Virtualization) | Light (Venv / Native Process) |
| **RAM Usage** | High (Daemon + Container Overhead) | **Near Zero** (Native OS usage only) |
| **Disk Space** | Heavy (Duplicated OS layers) | **Tiny** (Shared OS libs) |
| **Management** | `docker compose up` | `systemd-compose . up` |
| **Management** | `docker compose up` | `systemd-compose up` |
| **Best For** | Cloud / Complex Deps | Raspberry Pi / Home Lab / Old PCs |

---

## ✨ Features

* **Rootless Architecture:** Runs entirely in userspace using `systemd --user`.
* **Docker-like CLI:** Commands like `up`, `down`, `ps`, and `logs` make it easy to learn.
* **Docker-like CLI:** Commands like `up`, `down`, `ps`, `logs` and `exec` make it easy to learn.
* **Isolated Environments:** Automatically creates and manages a Python `venv` for each project.
* **Namespaced Services:** Every app runs as `sdc-<APP_NAME>.service`, so it can never replace one of your other user services.
* **Conflict Detection:** Prevents two project folders from claiming the same app name.
* **Resource Limits:** Cap an app's memory and CPU so one runaway app can't take down your Pi.
* **Crash Detection:** `up` waits until your app is actually running (and listening on its port) and shows the logs if it isn't. Crash loops end as **CRASHED** instead of restarting forever.
* **Global Dashboard:** `ps` shows every app you've started, wherever its project folder lives.
* **Flexible:** Runs anything installed in the venv: plain Python scripts, Uvicorn (FastAPI), Gunicorn (Flask/Django), or `python -m http.server` for a static site.
Expand Down Expand Up @@ -54,7 +55,7 @@ The script asks before changing anything. It:

1. Installs missing packages: `python3`, `python3-venv`, `python3-pip`, `git`, `lsof`, `iproute2`, `dbus-user-session`, `libpam-systemd`.
2. Fixes minimal systems such as DietPi (masked `systemd-logind`, missing `XDG_RUNTIME_DIR`).
3. Offers to add you to the `systemd-journal` group, so `logs` and `status` can show your apps' output on systems that don't keep a per-user journal. This group can read **all** system logs.
3. Makes sure `logs` and `status` can show your apps' output. If the system journal is kept only in memory (common on DietPi and other minimal images), you can't read your own apps' logs. The script offers to store the journal on disk (`/etc/systemd/journald.conf.d/99-systemd-compose.conf`), after which each user can read their own logs. If you decline, it offers the `systemd-journal` group instead, which can read **all** system logs.
4. Enables **linger** for your user, so apps start at boot and keep running after you log out.
5. Links `systemd-compose` into `/usr/local/bin`.

Expand All @@ -81,10 +82,10 @@ cd ~/my-new-bot
nano config.env # set APP_NAME="my-new-bot"

# Create the venv, install requirements.txt, install and start the service
systemd-compose . up
systemd-compose up

systemd-compose ps # see all apps
systemd-compose . logs # follow this app's output (Ctrl+C to exit)
systemd-compose logs # follow this app's output (Ctrl+C to exit)
```

---
Expand Down Expand Up @@ -141,6 +142,8 @@ REQUIRE_PORT="false"
| `REQUIRE_PORT` | No | `true` (default) or `false`. See above. |
| `PYTHON_BIN` | No | Python used to create the venv. Default `python3`. Delete `venv/` and run `build` after changing it. |
| `STARTUP_TIMEOUT` | No | Seconds to wait for the port to start listening. Default `15`. |
| `MEMORY_MAX` | No | Memory limit for the app and all its workers, e.g. `300M`, `1G` or `25%` of RAM. Above it, the app is killed and restarted. Default: no limit. |
| `CPU_QUOTA` | No | CPU limit as a percentage of one core, e.g. `50%`, or `200%` for two cores. Default: no limit. |

`config.env` can use `${INSTALL_DIR}` (the project folder), `${APP_DIR}`, and any value from `.env`, such as `$PORT`.

Expand Down Expand Up @@ -170,20 +173,27 @@ Use systemd's format:

### Project Commands

Run these with the path to a project folder (`.` for the current folder).
Run these inside a project folder, or put the folder first: `systemd-compose ~/my-app up`.

| Command | Description |
| --- | --- |
| `systemd-compose . up` | **Recommended.** `build` + `run` (like `docker compose up`). |
| `systemd-compose . build` | Creates the `venv` and installs `requirements.txt`. Stops if `pip` fails. |
| `systemd-compose . run` | Writes the service file and (re)starts the app, then waits until it is running. Safe to re-run while the app is running. |
| `systemd-compose . restart` | Restarts the app with the existing service file. Picks up `.env` changes. |
| `systemd-compose . stop` | Stops the app (keeps the venv and logs). It still starts again at boot. |
| `systemd-compose . down` | Stops the app, removes the service, and **deletes the venv**. |
| `systemd-compose . status` | State, PID, memory of all workers, restart count, port and recent log lines. |
| `systemd-compose . logs` | Follows the app's logs. |
| `systemd-compose up` | **Recommended.** `build` + `run` (like `docker compose up`). |
| `systemd-compose build` | Creates the `venv` and installs `requirements.txt`. Stops if `pip` fails. |
| `systemd-compose run` | Writes the service file and (re)starts the app, then waits until it is running. Safe to re-run while the app is running. |
| `systemd-compose restart` | Restarts the app with the existing service file. Picks up `.env` changes. |
| `systemd-compose stop` | Stops the app (keeps the venv and logs). It still starts again at boot. |
| `systemd-compose down` | Stops the app and removes the service. Keeps the venv, so the next `up` is fast. |
| `systemd-compose down --volumes` | Same, and also **deletes the venv**. |
| `systemd-compose status` | State, PID, memory of all workers (and the limit), restart count, port and recent log lines. |
| `systemd-compose logs` | Follows the app's logs. Add journalctl options to change that: `logs -n 200`, `logs --since "1 hour ago"`, `logs --since today -f`. |
| `systemd-compose exec <command>` | Runs a one-off command like the app runs: venv first on `PATH`, variables from `.env`, inside `APP_DIR`. E.g. `exec python manage.py migrate`, `exec pip list`, `exec python` for a shell. |
| `systemd-compose config` | Prints the service file `run` would write, and whether the installed one is out of date. Changes nothing. |

**`run` or `restart`?** Values in `config.env` (including `$PORT` in `ARGS`) are written into the service file. After changing `config.env` or `PORT`, use `run` or `up`. For other `.env` changes, `restart` is enough.
`systemd-compose --help` lists all commands.

To manage a project folder whose name matches a command (e.g. a folder called `logs`), write it as a path: `systemd-compose ./logs up`.

**`run` or `restart`?** Values in `config.env` (including `$PORT` in `ARGS`) are written into the service file. After changing `config.env` or `PORT`, use `run` or `up`. For other `.env` changes, `restart` is enough. Not sure? `systemd-compose config` tells you whether the service file is out of date.

### Global Commands

Expand Down Expand Up @@ -238,6 +248,17 @@ ARGS="-c ${INSTALL_DIR}/${APP_DIR}/gunicorn.conf.py --bind 0.0.0.0:$PORT"
* Wrap an argument containing spaces in quotes, e.g. `ARGS="--name 'My App'"`.
* `%` and `$` are passed through literally (systemd-compose escapes them), so a log format like `'%(h)s %(r)s'` is safe.

### Resource Limits

```bash
MEMORY_MAX="300M" # the app and all its workers together
CPU_QUOTA="50%" # half of one CPU core
```

Run `systemd-compose run` to apply. `status` shows memory use next to the limit. An app that goes over `MEMORY_MAX` is killed and restarted, so set it well above normal use.

Limits need the kernel's memory/CPU controllers to be available to user services (cgroup v2, the default on current Debian, Ubuntu and Raspberry Pi OS). If they aren't, `run` warns that the limit won't be enforced.

### Startup Check & Crash Handling

After starting the app, `run`/`up`/`restart` watch it before reporting success:
Expand Down Expand Up @@ -282,7 +303,7 @@ systemctl --user daemon-reload
| `Name Conflict!` | Another project folder already uses this `APP_NAME`. Change `APP_NAME` in this project's `config.env`. |
| `Port 8000 is already in use` | Another program (or another app) is listening on `PORT`. Change `PORT` in `.env`, or stop the other program. |
| `running but not listening on port …` | The app started but isn't using `$PORT`. Check `ARGS` binds to `$PORT`, or raise `STARTUP_TIMEOUT`. |
| `logs` shows nothing / "No journal files were found" | Accept the `systemd-journal` group in `host-setup.sh`, then log out and back in. |
| `logs` shows nothing / "No journal files were found" | Re-run `./host-setup.sh` and accept storing the journal on disk. Check with `systemd-analyze cat-config systemd/journald.conf \| grep Storage=`: it should end with `persistent`, or with `auto` while `/var/log/journal` exists. If you chose the `systemd-journal` group instead, log out and back in. |
| Apps stop when you log out / don't start at boot | Linger is off. Run `loginctl enable-linger $USER`. |
| `Failed to connect to bus` | Re-run `./host-setup.sh` and open a new shell. |

Expand All @@ -306,7 +327,7 @@ CI runs both on every pull request.

```bash
# Remove each app (run in each project folder)
systemd-compose . down
systemd-compose down --volumes

# Remove the command
sudo rm /usr/local/bin/systemd-compose
Expand Down
109 changes: 87 additions & 22 deletions host-setup.sh
Original file line number Diff line number Diff line change
@@ -1,13 +1,5 @@
#!/bin/bash

# --- OS COMPATIBILITY CHECK ---
if ! command -v apt-get &> /dev/null; then
echo "❌ Error: This script supports Debian/Ubuntu based systems only."
echo " (Raspbian, DietPi, Ubuntu, Mint, etc.)"
echo " Reason: It relies on 'apt' for package management."
exit 1
fi

# Dependencies:
# python3-venv: Critical for creating isolated environments
# python3-pip: Required for package management
Expand All @@ -21,6 +13,91 @@ TARGET_LINK="/usr/local/bin/systemd-compose"
SCRIPT_DIR="$(dirname "$(realpath "$0")")"
SOURCE_SCRIPT="$SCRIPT_DIR/systemd-compose.sh"

JOURNAL_DIR="${JOURNAL_DIR:-/var/log/journal}"
JOURNALD_DROPIN_DIR="${JOURNALD_DROPIN_DIR:-/etc/systemd/journald.conf.d}"
JOURNALD_DROPIN="$JOURNALD_DROPIN_DIR/99-systemd-compose.conf"

# --- HELPERS: LOG ACCESS ---

# Prints journald's effective Storage= setting: auto, persistent, volatile or none
journal_storage_setting() {
local storage
storage=$(systemd-analyze cat-config systemd/journald.conf 2>/dev/null \
| sed -n 's/^[[:space:]]*Storage=[[:space:]]*//p' | tail -n 1)
echo "${storage:-auto}"
}

# journald only writes a separate journal per user (which that user may read
# without extra groups) when logs are stored on disk.
journal_is_persistent() {
case "$(journal_storage_setting)" in
persistent) return 0 ;;
auto) [ -d "$JOURNAL_DIR" ] ;;
*) return 1 ;;
esac
}

enable_persistent_journal() {
sudo mkdir -p "$JOURNALD_DROPIN_DIR" &&
printf '# Added by systemd-compose host-setup.sh\n[Journal]\nStorage=persistent\n' \
| sudo tee "$JOURNALD_DROPIN" >/dev/null &&
sudo systemctl restart systemd-journald &&
sudo journalctl --flush
}

# Makes sure `systemd-compose logs` can show the user's app logs. Prefers
# storing the journal on disk (each user reads only their own logs) over the
# systemd-journal group (which can read every log on the system).
ensure_log_access() {
if id -nG "$USER" | grep -qw "systemd-journal"; then
echo "✅ User is in the 'systemd-journal' group (logs visible)."
return 0
fi
if journal_is_persistent; then
echo "✅ The journal is stored on disk, so you can read your apps' logs."
return 0
fi

echo "⚠️ The system journal is only kept in memory (Storage=$(journal_storage_setting))."
echo " In that mode you can't read your own apps' logs without extra permissions."
echo " Recommended: store the journal on disk. Each user can then read their own logs."
echo " (journald limits it to 10% of the disk, 4 GB at most.)"
read -p "❓ Store the journal on disk? (Requires sudo) [y/N] " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
if enable_persistent_journal && journal_is_persistent; then
echo "✅ The journal is now stored on disk ($JOURNALD_DROPIN)."
return 0
fi
echo "❌ Couldn't switch the journal to disk. Another setting may override it:"
echo " systemd-analyze cat-config systemd/journald.conf"
fi

echo " Alternative: join the 'systemd-journal' group. Note: it can read ALL system logs."
read -p "❓ Add user '$USER' to the group? (Requires sudo) [y/N] " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
sudo usermod -aG systemd-journal "$USER"
echo "✅ User added to group."
GROUPS_CHANGED=1
else
echo "⚠️ Skipped. 'systemd-compose logs' may show nothing."
fi
}

# The tests source this file to check the helpers above; stop here for them.
if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then
return 0
fi

# --- OS COMPATIBILITY CHECK ---
if ! command -v apt-get &> /dev/null; then
echo "❌ Error: This script supports Debian/Ubuntu based systems only."
echo " (Raspbian, DietPi, Ubuntu, Mint, etc.)"
echo " Reason: It relies on 'apt' for package management."
exit 1
fi

echo "=== 🛠️ Host Dependency Check ==="

MISSING_DEPS=()
Expand Down Expand Up @@ -129,21 +206,9 @@ fix_shell_environment() {
ensure_logind_service
fix_shell_environment

# --- ACTION: LOG PERMISSIONS ---
# --- ACTION: LOG ACCESS ---

if groups "$USER" | grep -q "systemd-journal"; then
echo "✅ User is in 'systemd-journal' group (Logs visible)."
else
echo "⚠️ User '$USER' is NOT in 'systemd-journal' group."
echo " You won't be able to see app logs without this."
read -p "❓ Add user to group now? (Requires sudo) [y/N] " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
sudo usermod -aG systemd-journal "$USER"
echo "✅ User added to group."
GROUPS_CHANGED=1
fi
fi
ensure_log_access

# --- ACTION: ENABLE LINGER ---

Expand Down
Loading
Loading