The Ur Project

Documenting the craft of stacking things so they remain stacked. For a very long time.

instance-setup(8)

NAME

instance-setup - read a cloud config-drive into systemd credentials

SYNOPSIS

instance-setup [--root DIR] [--openstack-config-drive PATH | -u FILE] [--no-network] [--seed-timeout N]

instance-setup --dump [--openstack-config-drive PATH | -u FILE]

instance-setup --run-commands [--root DIR]

instance-setup --ordering-probe [--root DIR]

instance-setup --offline --root DIR (-u FILE | --openstack-config-drive PATH)

DESCRIPTION

You boot the same image a hundred times, and every copy needs to be a little different: its own hostname, an account for whoever is going to log in, that person's SSH key. The cloud, or your local libvirt tooling, hands that over on a small disk called a config-drive. Something in the image has to read it on first boot and act on it.

instance-setup is that something. It finds the config-drive, parses the cloud-config document on it, and writes the result into the systemd credential store at /run/credstore/. And then it's done. It doesn't create a single user itself.

Why doesn't it just do the work?

Because systemd already does. Creating users, writing files, setting the hostname, running first-boot commands: every one of those is a job a systemd component in the image already knows how to do, idempotently and in the right order. cloud-init grew around ninety modules doing that work itself. This doesn't need to.

So instance-setup is a translator between a format clouds emit and a format systemd consumes. Seed in, credentials out, no side effects beyond the credential store. Stock systemd units already carry the matching ImportCredential= lines, so no unit in the image needs changing to pick up what this writes.

When does it run?

On every boot. It runs from instance-setup-pre-network.service, early, and ordered before everything that consumes what it writes. /run/credstore is a tmpfs, so each boot starts empty and everything is derived again from the seed. The consumers are idempotent, so there's no "did I already run?" stamp to go stale.

DATASOURCES

Where does the seed come from?

A seed is found by filesystem label, on iso9660 or vfat:

config-2
OpenStack. Documents at openstack/latest/meta_data.json, openstack/latest/user_data and openstack/latest/network_data.json.
cidata
NoCloud, which is what local libvirt tooling emits. Documents at meta-data, user-data and network-config.

The label search ignores case. A lowercase label often reaches the medium uppercased, because iso9660 volume identifiers are upper case by convention.

network-config and network_data.json are read, but not applied yet. A seed carrying one gets a warning, and its user-data is still processed.

Each document is capped at 4 MiB. The seed is mounted nodev,nosuid,noexec,ro, and unmounted as soon as the documents are in memory. Remember that the seed is unauthenticated: anyone who can attach a disk to the machine can write one. SECURITY.md in the source tree has the full threat model.

KEYWORDS

These are the cloud-config keywords it understands, and what actually carries out each one. The input format is in cloud-config(5).

users
systemd-userdb-load-credentials, via userdb.user.<name>.
groups
The same, via userdb.group.<name>.
ssh_authorized_keys
The sshAuthorizedKeys field inside the userdb record.
sudo (per user)
A sudoers drop-in via tmpfiles.extra, checked with visudo -c.
hostname, fqdn
/etc/hostname via tmpfiles.extra, plus a command that applies it on this boot.
repos
pkg drop-ins under /etc/pkg/ via tmpfiles.extra. See pkg-repos.d(5).
write_files
systemd-tmpfiles, via tmpfiles.extra.

An unknown keyword is a warning and a skip, never a failed boot.

OFFLINE MODE

Can I bake the seed in instead?

Yes. --offline provisions a root that isn't booted, like an installer's target, mounted somewhere, instead of the running system. That's useful when you know the account and hostname at install time and don't want a seed disk hanging around afterwards.

The credential store is a tmpfs on the installed system, so writing credentials there would be pointless. Offline mode writes what the first boot would have left behind instead:

  • userdb drop-ins under /etc/userdb/, split the way userdbctl load-credentials splits them: <name>.user without the privileged section, <name>.user-privileged holding it, the <uid>.user symlinks, <name>.group for each group the seed creates, and a <user>:<group>.membership record per supplementary group;
  • the home directory of each regular account, 0700, owned by it, with /etc/skel copied in;
  • /etc/sudoers.d/50-instance-setup-<name> for a per-user sudo, still checked with visudo -c;
  • /etc/hostname;
  • the repos drop-ins and trust anchors under /etc/pkg/;
  • every write_files entry;
  • /etc/instance-setup/baked, last.

uids and gids are allocated from the target alone, from its /etc/passwd, /etc/group and /etc/userdb, upward from 1000. An account the target already has in /etc/userdb keeps its ids, so baking the same seed twice writes the same records.

Why is offline mode so strict?

Because nobody is watching. On a live boot, a warning lands in the journal and on the console. A bake that quietly drops half the seed hands you an installed system that's subtly wrong, and you find out the first time you try to log in. So offline mode refuses, it doesn't skip.

It never falls back to the host. It needs --root and an explicit seed (-u or --openstack-config-drive), it never searches for a seed device, and it never reads the host's accounts.

Anything in the seed it can't bake fails the run: a keyword that isn't implemented, a command for the first boot such as runcmd, script user-data, network-config, a user key that live mode ignores, a sudoers drop-in visudo couldn't check. So does an account that's already in the target's /etc/passwd, a group /etc/group already gives a different gid, a supplementary group the target doesn't define in /etc/group, /etc/userdb or sysusers.d, a file owner the target doesn't know, or an owner it can't set without root. All of that is checked before anything is written. A refused bake leaves the root as it found it.

OPTIONS

--offline
Bake the seed into the root given by --root. See OFFLINE MODE.
--dump
Parse the seed and print what was found: the layout and the size of each document, never their contents. Writes nothing. Needs no privileges beyond reading the seed.
--openstack-config-drive PATH
Process this config drive instead of searching by label. PATH can be a block device or an already-unpacked directory. The directory form needs no privileges, and it's how the composition layer reads a seed at image build time. An image file isn't mounted directly, since mount(2) takes a block device. Attach it first with losetup --find --show --read-only FILE and pass the loop device.
-u, --user-data-file FILE
Read user-data from FILE instead of from a seed device.
--root DIR
Write under DIR instead of /. The sink takes its root as a parameter, so the whole mapping layer can be exercised unprivileged against a temporary tree. With --offline, this is the root to bake into, and it's required.
--no-network
Use local datasources only. Accepted, and a no-op today: every datasource there is today is local, so the restriction already holds. instance-setup-pre-network.service passes it anyway, so it stays true if an HTTP metadata service is ever added, without the unit needing an edit then.
--seed-timeout N

Seconds to wait for a seed device to show up. Default 5.

Why wait at all? This unit starts once udevd is running, but not after udev finishes coldplug. A seed on a device that's slow to appear can otherwise be missed. 0 turns the wait off, which is right for an image that never carries a seed.

A root baked with --offline defaults to 0. It still looks once, so a seed attached from the start is still used, in a VM or with instance-setup.seed=search on the kernel command line. See KERNEL COMMAND LINE.

--run-commands
Run the commands an earlier run derived from the seed. Invoked by instance-setup-commands.service, not useful by hand.
--ordering-probe
Write one probe credential and exit, without reading any seed. It answers one question: is this unit early enough that a stock systemd consumer still sees what it writes? That question comes back every time the unit graph around it changes. If the ordering is right, /run/instance-setup-ordering-probe exists once systemd-tmpfiles-setup.service has run.
-V, --version
Print the version and exit.
-h, --help
Show usage.

KERNEL COMMAND LINE

instance-setup.seed=search
On a root baked with --offline that isn't running in a VM, search for a seed device by label anyway. Without it, such a machine never looks, so a stick labelled cidata or config-2 left in at boot provisions nothing. VMs, roots that were never baked, and an explicitly named seed aren't affected.

EXIT STATUS

0 when it provisioned something. Also 0 when it found no seed, or a seed with no user-data: booting an instance without one is perfectly normal. Script user-data (starting with #!) isn't implemented yet, and is a warning and a 0 as well. It exits non-zero on a malformed seed, such as user-data that isn't UTF-8 or a document whose top level isn't a mapping, or when an explicitly named seed can't be read.

A non-zero exit never blocks the boot. sysinit.target only Wants this unit, so the instance comes up regardless, and the failure is in the journal and on the console.

--dump exits 1 when there's no seed to dump. --run-commands exits 0 even when a command fails, and logs how many did.

With --offline it exits 0 only when everything in the seed was baked, and non-zero with the reason on standard error otherwise.

FILES

/run/credstore/
Where credentials are written. The directory is mode 0700, each file 0400.
/run/instance-setup/commands.json
Commands derived from the seed, as an array of argv arrays, run by instance-setup-commands.service. Never a shell script: the contents came from an unauthenticated medium.
/etc/pkg/repos.d/<priority>-<name>.toml, /etc/pkg/<name>.root.json
Repository drop-ins and trust anchors written from the seed's repos keyword, through tmpfiles.extra. Format: pkg-repos.d(5).
/etc/userdb/
Where systemd-userdb-load-credentials persists the records it loads, and where --offline writes them.
/etc/instance-setup/baked
Written by --offline. When it exists, the seed wait defaults to 0, and on bare metal the seed search is off.

NOTES

This is the successor in scope, not in name, to micro-config-drive (binary ucd), which Intel archived on 2025-08-07. It's a lot smaller than that was, because systemd now does every job that tool had to implement itself.

The tmpfiles argument limit

Every keyword that produces a file rather than a credential does it by writing a tmpfiles.d line into the tmpfiles.extra credential, with the file's whole content as that line's last argument. systemd-tmpfiles runs that argument through specifier expansion in a PATH_MAX-sized buffer. So content over 4095 bytes, counted after escaping, can't be carried. That limit applies to sudo drop-ins, /etc/hostname, repos trust anchors and write_files alike.

A TUF root with one key is well under 1KB, so real anchors fit. Content that doesn't fit is reported and the file is skipped. That's better than the alternative: systemd-tmpfiles drops an over-long line, writes no file, and still exits 0.

Why does my % turn into %%?

For the same reason. A literal % in file content is escaped to %% before it's written, because an unknown specifier isn't a warning either, the line is just silently dropped. Ordinary sudoers syntax such as %wheel is the obvious way to hit this, and the file on disk still says %wheel.

SEE ALSO

cloud-config(5), pkg-repos.d(5), systemd.system-credentials(7), systemd-userdbd(8), systemd-tmpfiles(8), userdbctl(1)