palacky.net
LXD · cloud-init · one file

Run CI and Go.

cigo turns a plain cloud-config file into a repeatable LXD container deploy. Your template stays valid cloud-config; cigo only adds ordered actions, safe secrets and re-deploys that actually change things.

One file. No install, no inventory, no state file, no new language to learn.

Why it exists

Halfway between raw cloud-init and a full-blown config manager

A template you already know

The file stays a normal cloud-config. cigo directives are one-line cigo: statements that are stripped before upload, so existing cloud-init knowledge and tooling keep working.

Bootstrap vs. pipeline

First boot belongs to cloud-init. Everything repeatable — packages, files, renders, service restarts — goes into the pipeline and runs on every call.

Update without guessing

redeploy reapplies config, devices and actions. configure changes only LXD state — no actions, no cloud-init — so it is fast and safe on a running container.

Secrets stay out of sight

Secrets come from a protected file or an environment variable and are only available to the renderer. Never in user-data, never in arguments, never printed.

Rendered where it belongs

Jinja2 runs on the host and only the result is pushed into the container. No container needs Python, and a broken template fails before anything changes.

Migration is a flag

cigo: from old-container creates a replacement on a newer image and carries over eligible config and devices — the manual part of a distro upgrade.

How it works

Two layers, one file

LayerRuns
Bootstrap (cloud-config)first boot only
Pipeline (cigo:)every invocation

That split is the whole idea. Anything that must survive a re-deploy lives in the pipeline; anything that really is first-boot-only stays in cloud-config.

Declare, do not script

Each cigo: line is one action or one piece of container state. They run in the order you write them, and the same file works for the first deploy and every re-deploy after it.

#cloud-config
cigo: image ubuntu:24.04
cigo: config limits.memory=2GiB
cigo: device disk www source=/srv/www path=/var/www
cigo: render files/vhost.j2 /etc/apache2/site.conf 0644
cigo: render files/extra.conf.j2 /etc/apache2/conf-available/extra.conf 0644
cigo: run systemctl reload apache2

package_update: true
package_upgrade: true

--dry-run prints the whole plan, --check validates everything it can without touching LXD, --dump-user-data shows the exact cloud-config that will be uploaded.

Four verbs

One intent per command

VerbTargetApplies
createmust not existconfig, devices, cloud-init, actions
redeploycigo-managedconfig, devices, actions
configureany existingconfig and devices only
adoptcreated elsewhereconfig and devices

Nothing happens by accident

create refuses a container that already exists, and redeploy refuses one cigo did not build. The old ambiguity — a typo in --name silently turning a create into a partial update — is gone.

--check turns any of them into a read-only drift report and exits non-zero when the container no longer matches the template, so it doubles as a CI gate.

# change live LXD state only, no actions, no cloud-init
./cigo.sh configure --name web-01 --template templates/lamp --check

# take over a container that was created by hand
./cigo.sh adopt --name legacy-web --template templates/lamp
Starter templates

Copy, adjust, deploy

LAMP

Apache, PHP and MySQL for a small site.

Apache hosting

One virtual host, static document root.

Nginx proxy

Reverse proxy in front of an app container.

MySQL server

Standalone database, no remote access by default.

ProFTPD SFTP

SFTP-only, host key supplied as a secret.

Postfix + Dovecot

Virtual mailboxes backed by MySQL.

WireGuard

VPN endpoint with the private key as a secret.

BIND DNS

Authoritative zone, rendered and checked.

They are starting points, not hardened appliances. Public mail, DNS, VPN and file-transfer services need their own DNS, firewall, TLS, monitoring and backups.

Get started

Three steps

  1. Copy cigo.sh next to your templates.
  2. Write or copy a template.yaml.
  3. Run ./cigo.sh create --name web-01 --template templates/lamp.

Later, redeploy applies template changes and configure changes container settings only. Add --yes in scripts.

What you need

On the host: Bash, the lxc client, jq and python3-jinja2 for rendering. In the container: an image with cloud-init, plus rsync and tar for copy/sync actions.

cloud-init on the host is optional — it only adds schema validation to --check.

Scope

What it deliberately is not

Bounded on purpose

No loops, no conditions, no roles, no inventory, no cluster support, no Incus. cigo targets small labs and infrastructures with a handful to a few dozen single-purpose containers. For anything larger, reach for Ansible or Terraform.

Templates are code

A template runs commands as root in a container and can mount host paths through LXD. Templates are not sandboxed or signed, so read them before running them — including the ones shipped here.

The name

CI + GO

cigo is short for CI and go: run it, then get on with your work. In Czech, cigo also happens to be slang for a cigarette — so locally the pitch is “run cigo, then go for a cigo”: the container is up, take a break. It is a pun, not an endorsement of smoking.

The second meaning of go is the point: the tool should disappear from your attention the moment the container is ready.