Technology
How a Deployment Works in Gisila Panel
From clicking Deploy to a running systemd service: a step-by-step walkthrough of the Gisila Panel deployment engine.
Gumisofts
You've created an app in Gisila Panel, wired up your git repository, and clicked Deploy. What happens next?
This post walks through the full deployment pipeline, from the API call to a running service behind Nginx with TLS, so you know exactly what the panel is doing on your server.
The big picture
Gisila Panel uses a three-tier architecture for deployments:
You (browser) → Dart API → Redis queue → gisila-worker → gisila-agent → systemd
↓ ↓
Postgres Build logs → WebSocket → your browser- Dart API: receives your deploy request, records state in Postgres, enqueues a job
- Redis: job queue for deployments, lifecycle actions, vhost updates, SSL issuance; pub/sub for live log streaming
- gisila-worker: background process that pops jobs and orchestrates the agent
- gisila-agent: privileged host-side binary (runs as root via sudo) that touches systemd, Nginx, AppArmor, and the filesystem
- systemd: actually runs your app as an isolated process
The API and worker run as the unprivileged gisila system user. The only privileged code path is sudo /usr/local/bin/gisila-agent …, gated by a tightly scoped sudoers rule.
Step 1: You trigger a deployment
From the dashboard or API:
POST /apps/{id}/deployments/
Content-Type: application/json
Authorization: Bearer
{
"sourceType": "git",
"gitCommitSha": "abc123..."
} Supported source types:
| Source | Description |
|---|---|
git | Clone or pull from a configured repository at a specific commit |
binary | Upload a pre-compiled binary artifact |
zip | Upload a ZIP archive of source code |
The DeploymentsService creates a new Deployment row with status queued, sets the app's status to building, and pushes a JSON payload onto the Redis list gisila:queue:deployments.
You immediately see the deployment appear in the UI with status Queued.
Step 2: The worker picks up the job
gisila-worker runs continuously, blocking on BLPOP gisila:queue:deployments. When your job arrives, it deserializes the payload and calls DeploymentWorker.onDeployment.
The worker knows:
- Which app to deploy (ID, slug, runtime, build/start commands)
- Which source to use (git URL + commit, artifact path, or ZIP)
- Resource limits (memory, CPU, task count)
- Environment variables to inject
It then shells out to gisila-agent in sequence. Each subcommand's stdout/stderr is captured, persisted to BuildLog rows, and published on the Redis channel gisila:logs:build:.
Your browser's Logs tab is a WebSocket subscribed to that channel, so you see build output in real time, line by line.
Step 3: Provision
gisila-agent provision \
--user app_abc123 \
--work-dir /srv/apps/app_abc123 \
--env-file /srv/apps/app_abc123/shared/.envThis step is idempotent: safe to run on every deployment.
What it does:
Creates the Linux system user
app_abc123if it doesn't exist (useradd --system --no-create-home --shell /usr/sbin/nologin)Creates the directory layout:
/srv/apps/app_abc123/ ├── current/ ├── releases/ ├── shared/ │ └── .env ← your environment variables ├── tmp/ └── logs/Sets ownership and permissions (
0750, owned by the app user)Writes the
.envfile from the panel's env var store
If the user and directories already exist from a previous deployment, this step is a no-op.
Step 4: Build
gisila-agent build \
--user app_abc123 \
--work-dir /srv/apps/app_abc123 \
--runtime go \
--source-type git \
--git-url https://github.com/you/your-api.git \
--git-commit abc123 \
--build-command "go build -o bin/server ./cmd/server"The build step depends on your runtime:
| Runtime | Build process |
|---|---|
go | go build with your build command |
rust | cargo build --release |
dart | dart pub get + dart compile exe |
node / bun | npm ci or bun install |
python | pip install -r requirements.txt |
zig | zig build |
binary | Copy uploaded artifact to release directory |
static | Copy static files (HTML/CSS/JS) to release directory |
The output lands in /srv/apps/app_abc123/releases/. A symlink at current/ will point here once the deployment succeeds.
Build logs stream to your browser throughout this step. If the build fails, the deployment is marked failed, the app status reverts, and you see the error in the logs tab.
Step 5: Apply systemd unit
gisila-agent apply-unit \
--user app_abc123 \
--work-dir /srv/apps/app_abc123 \
--start-command "/srv/apps/app_abc123/current/bin/server" \
--memory-max 256M \
--cpu-quota 50% \
--tasks-max 128 \
--port 4127The agent generates two files:
Systemd unit (/etc/systemd/system/gisila-app_):
[Unit]
Description=Gisila app: my-api
After=network.target
[Service]
Type=simple
User=app_abc123
Group=app_abc123
WorkingDirectory=/srv/apps/app_abc123/current
EnvironmentFile=/srv/apps/app_abc123/shared/.env
ExecStart=/srv/apps/app_abc123/current/bin/server
Restart=on-failure
RestartSec=5
# Resource limits
MemoryMax=256M
CPUQuota=50%
TasksMax=128
# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
RestrictNamespaces=true
MemoryDenyWriteExecute=true
LockPersonality=true
RestrictRealtime=true
ProtectKernelTunables=true
PrivateDevices=true
SystemCallArchitectures=native
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
ReadWritePaths=/srv/apps/app_abc123/shared /srv/apps/app_abc123/tmp /srv/apps/app_abc123/logs
ReadOnlyPaths=/srv/apps/app_abc123/current /srv/apps/app_abc123/releases
[Install]
WantedBy=gisila-apps.targetAppArmor profile (/etc/apparmor.d/gisila-app_):
A default-deny profile scoped to the app's work directory and runtime dependencies. Loaded with apparmor_parser -r.
Then: systemctl daemon-reload.
Step 6: Apply Nginx vhost
gisila-agent apply-vhost \
--hostname my-api.example.com \
--port 4127 \
--user app_abc123The agent writes an Nginx server block:
server {
listen 80;
server_name my-api.example.com;
location / {
proxy_pass http://127.0.0.1:4127;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Your app listens on 127.0.0.1:4127 (localhost only). Nginx is the public-facing entry point.
If you've configured a custom domain, a separate SSL job runs certbot --nginx to provision and install a Let's Encrypt certificate. Renewal is handled by certbot's standard cron, not by the panel.
Then: nginx -t && systemctl reload nginx.
Step 7: Restart
gisila-agent restart --user app_abc123Which runs:
systemctl restart gisila-app_Your app starts. systemd manages the process lifecycle. If it crashes, Restart=on-failure brings it back after 5 seconds.
Logs go to journald:
journalctl -fu gisila-app_The panel's Logs tab tails this stream via WebSocket when runtime log streaming is enabled.
Step 8: Success
The worker marks the deployment as active (isActive=true), deactivates the previous deployment, sets the app status to running, and records an AppEvent in the audit log.
Your app is live at https://my-api.example.com.
The whole pipeline typically takes 30 seconds to 3 minutes depending on build time.
What about rollbacks?
Every successful deployment keeps its release artifact in /srv/apps/app_xxx/releases/. To roll back:
- You select a previous deployment in the UI
- The panel swaps the
current/symlink to point at the old release gisila-agent restartreloads the previous binary
No rebuild required. The old artifact is already on disk.
Lifecycle operations
Beyond deploy, the panel supports:
| Action | What happens |
|---|---|
| Start | systemctl start gisila-app_ |
| Stop | systemctl stop gisila-app_ |
| Restart | systemctl restart gisila-app_ |
| Console | One-off command execution in the app's environment |
| Scale limits | Update MemoryMax / CPUQuota in the unit file and reload |
Each action goes through the same Redis queue → worker → agent pipeline.
Monitoring
The Metrics tab samples CPU and RAM directly from cgroups v2:
/sys/fs/cgroup/.../memory.current: current memory usage/sys/fs/cgroup/.../cpu.stat: CPU time consumed
No Prometheus required. No sidecar containers. The panel reads kernel accounting files directly.
Graphs update on a polling interval and show usage against your configured limits.
Multi-app on one node
Every app on your VPS goes through this same pipeline independently:
/srv/apps/
├── app_abc123/ → gisila-app_my-api.service → :4127 → my-api.example.com
├── app_def456/ → gisila-app_webhook.service → :4128 → hooks.example.com
├── app_ghi789/ → gisila-app_worker.service → :4129 → (internal only)
└── app_jkl012/ → gisila-app_dashboard.service → :4130 → dash.example.comEach app has its own Linux user, systemd unit, AppArmor profile, port, and Nginx vhost. They share nothing except the host kernel and the Nginx edge.
Port allocation is managed automatically from a configurable range (default: 4000–4999).
Try it yourself
The fastest way to see this pipeline in action:
git clone https://github.com/your-org/gisila-panel.git
cd gisila-panel
docker compose upOpen http://localhost:3000, create an account, add an app, and deploy. In dev mode (AGENT_MODE=dev), the worker logs the commands it would send to gisila-agent without touching systemd, so you can trace the full flow safely.
For a real end-to-end test, install on an Ubuntu VPS:
sudo bash infra/install.shThen deploy a simple Go or Python HTTP server and watch the logs stream in.
Further reading
- Architecture: full system design
- Deployment engine: agent subcommands and runtime matrix
- Security model: isolation guarantees in detail
- API reference: REST endpoints for CI/CD integration
Previous: ← Why We Skipped Docker