This is a repository containing Packer templates to create a hardened Ubuntu server.
There are templates available for creating a
- Azure virtual machine image
- QEMU
.qcow2disk image
Ubuntu 26.04 LTS (Resolute Raccoon)
is the default base image. Point var.iso_url and var.iso_checksum at
another directory under cloud-images.ubuntu.com
to build a different release, and set var.release_version to match.
The Ansible collection used to make the server a bit more secure is available in the konstruktoid/ansible-collection-hardening repository.
The collection is a set of independent, single-purpose roles rather than one umbrella role, so config/local.yml selects the roles to apply and configures them.
See the Packer builders and post-processors documentation on how to rewrite the templates for another platform.
Requires Packer, the Azure CLI (az), jq,
curl and a Microsoft Azure account.
Ensure the correct values are set in ubuntu-azure-vars.json before
validating the configuration and building the image.
azure_vars_export creates or resets the service
principal, exports the ARM_* variables needed to authenticate with Azure,
and detects the caller's public IP address as MY_IP_ADDRESS so the build
VM's network security group only allows inbound SSH from that address. It
must be sourced rather than executed, and it expects an existing az login.
The credentials are exported into the current shell only; never write them to
disk.
It also creates the resource group named in ubuntu-azure-vars.json if it
does not already exist, and assigns the principal the Contributor role
scoped to that group. ARM_LOCATION (default northeurope),
ARM_SUBSCRIPTION_ID, ARM_RESOURCE_GROUP_NAME, ARM_PRINCIPAL_NAME and
MY_IP_ADDRESS override the detected or file-derived values.
{
"image_offer": "ubuntu-26_04-lts",
"image_sku": "server",
"principal_name": "PackerPrincipal",
"resource_group": "PackerGroup",
"vm_size": "Standard_D2s_v3"
}az login --use-device-code --tenant <tenant_id>
source azure_vars_export
packer init -upgrade ubuntu-hardened-azure.pkr.hcl
packer validate -var-file ubuntu-azure-vars.json ubuntu-hardened-azure.pkr.hcl
packer build -timestamp-ui -var-file ubuntu-azure-vars.json ubuntu-hardened-azure.pkr.hclThe Azure build generates the same SPDX (.spdx.json) and CycloneDX
(.cdx.json) SBOMs as the local build and downloads them to a timestamped
subdirectory under output, named after the managed image.
Requires Packer, QEMU and
OVMF, the EDK II UEFI firmware, which is
the ovmf package on Debian and Ubuntu.
build_box.sh also checks that shellcheck, ssh-keygen and sha256sum
are on PATH, and runs shellcheck over the repository's shell scripts
before it builds anything.
To build the image, run bash build_box.sh. The script generates a throwaway
SSH keypair, then builds ubuntu-hardened-qemu.pkr.hcl:
Packer boots the official Ubuntu cloud image in QEMU and configures it on
first boot from a
NoCloud
cidata seed built from seed/user-data.pkrtpl.hcl
and seed/meta-data. There is no unattended installer step:
the cloud image is copied, resized to var.disk_size and grown by growpart.
The build fetches the cloud image, the pinned konstruktoid.hardening tag and
the pinned Syft release.
A build takes roughly twenty minutes on a host with a usable /dev/kvm, and
writes an image of about 3 GB, backed by a 20 GB virtual disk (var.disk_size).
Near the end of the build, an SBOM of the image is generated with Syft using scripts/sbom.sh. It runs before scripts/cleanup.sh, which has to stay last so the ephemeral provisioning keypair is always stripped, so the SBOM is a pre-cleanup snapshot and still lists packages and files that cleanup then purges.
Both templates also declare scripts/cleanup.sh as an
error-cleanup-provisioner, so the keypair is stripped even when an earlier
provisioner fails and the build is run with -on-error=run-cleanup-provisioner.
The generated .qcow2 disk image, its SPDX (.spdx.json) and CycloneDX
(.cdx.json) SBOM files, and a CHECKSUMS file covering those three are
stored in a timestamped subdirectory under output. The build's serial
console log (serial.log) and the guest's UEFI variable store
(efivars.fd) are left there as well.
By default the image ships with a single ubuntu account (password
ubuntu, see var.password/var.password_hash) and no persisted SSH keys.
config/local.yml sets sshd_password_authentication: false, so the built
image never accepts a password over SSH. Pass the public keys you intend to
log in with, otherwise the image has no SSH access at all: the ephemeral
keypair Packer provisions over is always stripped by
scripts/cleanup.sh before the build finishes.
bash build_box.sh -var 'ssh_authorized_keys=["ssh-ed25519 AAAA... you@example.com"]'Extra arguments given to build_box.sh are passed through to packer build.
An image built without ssh_authorized_keys is still reachable on the serial
console, where login authenticates against /etc/shadow and is unaffected
by the sshd setting. That is the recovery path, and it is why the account
keeps a password at all.
Note That password is a published default and it survives into the built image. It is meant for local testing. For anything else, override
var.passwordandvar.password_hashtogether, generating the hash withopenssl passwd -6.
Images are built locally rather than in CI, so a .qcow2 file carries no
provenance of its own; verify it against the CHECKSUMS file written beside
it.
What is attested is the build definition.
.github/workflows/slsa.yml runs on a push to
main or master, on a v* tag, and on a published release. It checksums
the templates, build_box.sh, run_qemu.sh, azure_vars_export, scripts/,
tools/, config/, seed/ and ubuntu-azure-vars.json into a single
hardened-images.sha256 file, and that file is the subject of the
SLSA build level 3 provenance generated by
slsa-github-generator.
Both appear on the
SLSA workflow runs,
where the hardened-images.sha256 artifact is kept for five days. For a tag,
the provenance is uploaded as a release asset by the generator, and the
hardened-images.sha256 file is attached to the same release by the
workflow's release job.
run_qemu.sh boots a built image with the same OVMF UEFI
firmware the build used and forwards a local port to the guest's SSH server.
With no argument it picks the most recently modified image under output:
bash run_qemu.sh
bash run_qemu.sh ./output/<build>/<build>.qcow2OVMF_CODE, OVMF_VARS_TEMPLATE, SSH_PORT and VM_MEMORY can be set in the
environment to override the defaults. The script falls back to software
emulation, with a warning, when /dev/kvm is not usable.
The guest runs with -snapshot, so everything written during a session is
discarded when the VM stops and the .qcow2 stays byte-identical to what
CHECKSUMS records. Booting an image to inspect it therefore cannot invalidate
its checksum. Remove the flag if a session needs to persist across reboots.
Once booted, connect over SSH as the ubuntu user with a key passed via
ssh_authorized_keys at build time. The image does not accept passwords over
SSH:
ssh -p 2222 ubuntu@localhostAn image built without such a key has no SSH access. Log in instead on the
serial console the script attaches to stdio, as ubuntu with the password
ubuntu (see Local qcow2 image).
.
├── .agents
│ └── skills # Agent skills, vendored from agent-instructions-skills
├── .claude
│ └── skills # Symlinks to .agents/skills, for Claude Code discovery
├── .github
│ ├── copilot-instructions.md # Authoritative security and quality rules
│ ├── instructions # Path-scoped review rules
│ └── workflows # Lint, SLSA provenance, Scorecard, dependency review,
│ # issue assignment
├── .pre-commit-config.yaml # gitleaks, shellcheck, ansible-lint, packer fmt
├── azure_vars_export # Sourced: exports ARM_* credentials and MY_IP_ADDRESS
├── build_box.sh # Builds the local .qcow2 image
├── CLAUDE.md # Repository guidance for coding agents
├── config
│ ├── ansible.cfg
│ ├── local.yml # Selects and configures konstruktoid.hardening roles
│ └── requirements.yml # konstruktoid.hardening and its dependencies
├── seed
│ ├── meta-data
│ └── user-data.pkrtpl.hcl # cloud-init NoCloud configuration
├── instructions # Coding, writing and governance standards, vendored
│ # from agent-instructions-skills
├── LICENSE
├── run_qemu.sh # Boots a built image locally
├── scripts
│ ├── azure.sh # Azure-specific image preparation
│ ├── cleanup.sh # Strips build leftovers; must always run last
│ ├── hardening.sh # Runs the Ansible provisioning step
│ └── sbom.sh # Generates SPDX and CycloneDX SBOMs with Syft
├── SECURITY.md
├── tools
│ └── vendor-agent-standards.sh # Re-vendors instructions/ and .agents/skills
├── ubuntu-azure-vars.json
├── ubuntu-hardened-azure.pkr.hcl
└── ubuntu-hardened-qemu.pkr.hclBoth templates are formatted with packer fmt and must validate before they
are built; build_box.sh runs packer validate itself. The same checks run in
CI via .github/workflows/lint.yml, which covers
packer fmt/validate, shellcheck, bash -n syntax checks, actionlint,
zizmor and ansible-lint.
Locally, install the hooks with pre-commit install, or run the whole set with
pre-commit run --all-files.
The pinned versions that a build depends on are set in the templates:
| Pinned thing | Where |
|---|---|
| Packer core and plugins | packer block in each *.pkr.hcl |
konstruktoid.hardening collection tag |
var.hardening_collection_version |
| The collection and its dependencies, for build and lint | config/requirements.yml |
| Syft | var.syft_version |
| Ubuntu cloud image and its checksum | var.iso_url / var.iso_checksum (QEMU only) |
| Agent skills and instructions | DEFAULT_UPSTREAM_REF / DEFAULT_UPSTREAM_COMMIT in tools/vendor-agent-standards.sh |
The templates hand these to the provisioning scripts as environment
variables: both pass HARDENING_COLLECTION_VERSION, SYFT_VERSION and
BUILD_USERNAME, the Azure template also passes ANSIBLE_CONFIG, and the QEMU
template also passes POWEROFF_AFTER_CLEANUP. The
password sudo authenticates with is passed the same way, as SUDO_PASSWORD in
the provisioner environment file (use_env_var_file), so it never appears in
the command Packer logs or in the guest's process list, and --preserve-env
omits it so sudo drops it before the script runs. For the same reason the QEMU
build powers the machine off from scripts/cleanup.sh, which already runs as
root, and its shutdown_command is a bare true that only makes Packer wait
for that poweroff.
scripts/hardening.sh (collection tag), scripts/sbom.sh (Syft) and
scripts/cleanup.sh (username) each carry a matching fallback default so they
still run standalone. Change the version in the template variable, then keep
those defaults in sync with it.
config/requirements.yml pins konstruktoid.hardening itself together with
every collection it depends on, transitively. The templates upload it and
scripts/hardening.sh installs the whole file with --no-deps before
reinstalling the collection at the template's tag, so ansible-galaxy never
resolves a dependency from a mutable ref at build time. The ansible-lint job
in CI installs the same file, which is what makes the collection's roles
resolvable when the playbook is linted. Bumping the collection version means
checking that file against the upstream galaxy.yml and requirements.yml for
the new tag.
The contents of instructions/ and .agents/skills/ are vendored copies of
konstruktoid/agent-instructions-skills
and carry the upstream commit in a header comment. The script verifies that the
ref still resolves to the pinned commit before it replaces anything. Do not edit
them in place; bump DEFAULT_UPSTREAM_REF and DEFAULT_UPSTREAM_COMMIT and
re-run (the script also takes a ref as its first argument and the commit it must
resolve to as its second, for a one-off run):
bash tools/vendor-agent-standards.shDo you want to contribute? Great! Contributions are always welcome, no matter how large or small. If you found something odd, feel free to submit a issue, improve the code by creating a pull request, or by sponsoring this project.
Apache License Version 2.0