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.
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.
First boot belongs to cloud-init. Everything repeatable — packages, files, renders, service restarts — goes into the pipeline and runs on every call.
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. Attach a mount or a port to
one container with --device, no template edit needed.
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.
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.
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.
| Layer | Runs |
|---|---|
| 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.
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.
| Verb | Target | Applies |
|---|---|---|
create | must not exist | config, devices, cloud-init, actions |
redeploy | cigo-managed | config, devices, actions |
configure | any existing | config and devices only |
adopt | created elsewhere | config and devices |
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.
Devices declared in the template stay in sync. --device adds
one on the command line, --remove-device detaches it — cigo
never drops a mount or a port behind your back.
# change live LXD state only, no actions, no cloud-init
./cigo.sh configure --name web-01 --template templates/lamp --check
# attach a mount to one container, without touching the template
./cigo.sh configure --name web-01 \
--device 'disk data2 source=/srv/data2 path=/mnt/data2 shift=true'
# forward a host port to a service inside the container
./cigo.sh configure --name web-01 \
--device 'proxy app-http listen=tcp:0.0.0.0:8080 connect=tcp:127.0.0.1:80'
# detach a device again — removal is always explicit
./cigo.sh configure --name web-01 --remove-device data2
# take over a container that was created by hand
./cigo.sh adopt --name legacy-web --template templates/lamp
Apache, PHP and MySQL for a small site.
One virtual host, static document root.
Reverse proxy in front of an app container.
Standalone database, no remote access by default.
SFTP-only, host key supplied as a secret.
Virtual mailboxes backed by MySQL.
VPN endpoint with the private key as a secret.
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.
PATH — symlinks are followed, so bundled templates still
resolve../cigo.sh create --name web-01 --template templates/lamp.
Later, redeploy applies template changes and
configure changes container settings only. Add
--yes in scripts.
# optional: install it for every user
ln -s "$PWD/cigo.sh" /usr/local/bin/cigo.sh
cigo.sh create --name web-01 --template lamp
lxc client,
jq, and python3-jinja2 for rendering.rsync and tar for copy/sync actions.cloud-init on the host adds
schema validation to --check.And nothing else. No agent, no daemon, no inventory, no state file: what cigo knows about a container is stored on the container itself. Nothing extra is installed inside it — Jinja2 renders on the host, so the guest only ever sees the result.
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.
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.
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.