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. Attach a mount or a port to one container with --device, no template edit needed.

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.

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
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. Keep cigo.sh next to your templates, or link it into PATH — symlinks are followed, so bundled templates still resolve.
  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.

# optional: install it for every user
ln -s "$PWD/cigo.sh" /usr/local/bin/cigo.sh
cigo.sh create --name web-01 --template lamp

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.
  • Optional: 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.

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.