Skip to content

Architecture

Gisila Panel is a control plane for one Linux host. It has three programs of its own, plus two external services it depends on:

Piece Runs as Job
gisila-panel (API server) unprivileged gisila user HTTP API, WebSockets, the bundled web UI, the MCP endpoint. Records intent in Postgres and queues jobs in Redis.
gisila-worker root Pops jobs off Redis queues and runs them by calling the agent. Also runs the cron, metrics, health, backup and alert loops.
gisila-agent root (invoked by the worker) The only code that touches the host: Linux users, builds, systemd units, Nginx vhosts, AppArmor, certbot, ufw.
PostgreSQL 16+ external Panel state: users, teams, apps, deployments, encrypted secrets.
Redis external Job queues, log pub/sub, rate limits, health snapshots.

Postgres and Redis are dependencies, not components. The installer doesn’t install or manage them. They can run on the same host, another machine or a managed provider.

Read this page when you need to:

  • Debug why a change in the UI hasn’t reached the host yet (it’s queued, and the worker hasn’t run it).
  • Decide where to put Postgres and Redis.
  • Understand what an app’s systemd unit and Nginx vhost look like.
Browser / CLI / AI assistant
│ HTTPS (JWT or gsl_ API token)
▼
Nginx (panel vhost, :80/:443) ──► gisila-panel :8000
│ writes rows │ pushes jobs
▼ ▼
Postgres Redis ◄── log pub/sub
│ BLPOP
▼
gisila-worker (root)
│ exec
▼
gisila-agent (root)
│
┌──────────────┬──────────────┬────────────────┼──────────────┐
useradd systemd units Nginx vhosts AppArmor certbot / ufw
│
▼
app_ab12cd ─ gisila-app_ab12cd.service (one Linux user + unit per app)

The API never changes the host directly. A request such as “deploy app 42” inserts a Deployment row with status queued, sets the app to building, and pushes a JSON message onto a Redis list (gisila:queue:deployments). The API answers immediately. The worker, blocked in BLPOP, picks the job up and runs the agent’s subcommands in order. Lifecycle actions, vhost rewrites, certificate issuance, firewall changes, database operations and service installs all follow the same pattern, each with its own queue.

That’s why most mutating routes return {"detail": "… requested."} or a row in a pending state. Watch the row’s status, or open the matching WebSocket, to see the outcome.

gisila-agent is a single Dart binary with subcommands (provision, build, apply-unit, apply-vhost, activate-release, issue-cert, restart, app-health, firewall, postgres, and others). Every subcommand is idempotent, so re-running it converges on the declared state. Every argument is validated before it reaches useradd, systemctl or certbot. See Agent reference.

The worker runs as root so that it can call the agent directly without sudo. Many VPS providers set the kernel’s no_new_privileges flag, which blocks sudo from a sandboxed service.

Each app gets a Linux user app_<6 chars> and a directory under /srv/apps:

/srv/apps/app_ab12cd/
├── .env # app env vars, mode 0640, loaded by systemd EnvironmentFile=
├── current/ # the live build (read-only to the app)
├── releases/ # kept builds for instant rollback (read-only to the app)
├── shared/ # persistent read-write data (uploads, media)
├── tmp/ # scratch, also HOME for the process
└── logs/ # optional log directory

The app can only write to shared/, tmp/ and logs/.

The agent writes gisila-app_<id>.service (part of gisila-apps.target). Abridged:

[Service]
Type=simple
User=app_ab12cd
WorkingDirectory=/srv/apps/app_ab12cd/current
ExecStart=/srv/apps/app_ab12cd/current/app
Restart=always
Environment=PORT=4001
Environment=GISILA_APP_ID=42
EnvironmentFile=/srv/apps/app_ab12cd/.env
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
RestrictNamespaces=true
SystemCallArchitectures=native
ReadWritePaths=/srv/apps/app_ab12cd/shared /srv/apps/app_ab12cd/tmp /srv/apps/app_ab12cd/logs …
ReadOnlyPaths=/srv/apps/app_ab12cd/current /srv/apps/app_ab12cd/releases
AppArmorProfile=gisila-app_ab12cd
MemoryMax=256M
CPUQuota=50%
TasksMax=100

If the process fails 5 times within 300 seconds, systemd parks the unit in failed instead of restarting it forever, and the panel reports the app as crashed.

For a web app, the agent writes a vhost that proxies the app’s domains to 127.0.0.1:<port>. Static sites are served straight from the release directory with no process. The panel itself sits behind its own vhost (/etc/nginx/sites-available/gisila-panel) that proxies to 127.0.0.1:8000 and passes WebSocket upgrades through.

Build output from the agent is stored as BuildLog rows and published on gisila:logs:build:<deploymentId>. The WebSocket /ws/apps/{id}/build-logs/{deploymentId} bridges that channel to the browser. Runtime logs come from journald through the agent’s logs subcommand. See View logs and metrics.

A health monitor in the worker probes the mail stack, each managed service and each installed runtime toolchain on a timer. It caches the results in Redis, and auto-repairs failures (restart, then reinstall) with a cooldown. The …/health routes read that cache.

What happens when you click Deploy on a git app:

  1. POST /apps/42/deployments/ inserts Deployment(queued) and pushes a job.
  2. The worker runs gisila-agent provision (user, directories, .env).
  3. gisila-agent build clones the repo and runs the runtime’s build step as the app’s user.
  4. apply-unit writes the AppArmor profile and systemd unit. For a web app, apply-vhost follows.
  5. restart starts the new build, then app-health checks that it’s serving.
  6. On success the deployment becomes succeeded and active, and the app becomes running. If the health check fails, the deployment is failed and the app crashed, with the journal tail in the build log.