The official Ansible role that deploys the ivly invoicing stack on Raspberry Pi OS (Debian 13 / Trixie) using containers.
This Ansible role deploys ivly as a set of containers using Podman Quadlets:
- CouchDB as document store
- ivly-api (Go) as backend API server
- ivly-backoffice (SvelteKit SPA via nginx) as frontend
- ivly-printer (Go + TeX Live) for LaTeX PDF rendering
- ivly-validation (Java) for XRechnung/ZUGFeRD/Factur-X XML validation
- Caddy as reverse proxy
The setup is intended for internal networks and does not rely on public
port exposure or Let's Encrypt (though Caddy supports it via ivly_caddy_domain).
Browser
|
v
https://ivly.example.local
|
v
Caddy (reverse proxy)
|
+-- /api/* --> ivly-api:3000
+-- /health --> ivly-api:3000
+-- /* --> ivly-backoffice:8080
ivly-api:3000
|
+-- CouchDB (ivly-couchdb:5984)
+-- Printer (ivly-printer:8080)
+-- Validation (ivly-validation:8080)
+-- Ollama (remote, configurable via ivly_ollama_url)
All containers communicate via a shared Podman network (ivly).
All images are pulled from Docker Hub.
The reference setup (see example/playbook.yml) expects two Raspberry Pis:
| Host | Hardware | Role | Required? |
|---|---|---|---|
ivly |
Raspberry Pi 4/5 | Runs the ivly stack via this role | yes |
ollama |
Raspberry Pi 5 + Hailo AI HAT+ | Runs Ollama behind its own Caddy via head1328.hailo and head1328.hailo_ollama |
optional |
If you don't have a dedicated ollama host, point ivly_ollama_url at any reachable Ollama-compatible endpoint, or omit the LLM-related variables entirely - ivly will still work, just without LLM-backed assistance features.
- Podman + systemd Quadlets (rootless)
- Automatic container updates via Podman auto-update (configurable)
- No host networking
- Caddy supports both plain HTTP and automatic Let's Encrypt
- Systemd service dependencies (CouchDB/Printer/Validation -> API -> Caddy)
- Lifecycle managed via systemd handlers
- LLM/chat features via remote Ollama endpoint (see
head1328.hailoandhead1328.hailo_ollamaroles for setting up a private Ollama service)
- Podman with Quadlet support
- systemd
- Ansible collections:
containers.podman community.general
This role depends on head1328.podman which:
- Installs Podman
- Configures rootless Podman for a dedicated user
- Sets up user namespaces (subuid/subgid)
- Enables systemd user lingering
- Configures resource delegation for containers
The dependency is automatically installed when using ansible-galaxy with requirements.yml.
Install the role and its dependencies using Ansible Galaxy:
ansible-galaxy role install head1328.ivlyOr use a requirements.yml:
---
roles:
- name: head1328.ivlyThen install with:
ansible-galaxy install -r requirements.ymlSee example/playbook.yml for a complete example. Minimal usage:
- name: Deploy ivly
hosts: ivly
become: true
roles:
- role: head1328.ivly
ivly_couchdb_password: "{{ vault_ivly_couchdb_password }}"
ivly_caddy_domain: "ivly.example.local"The head1328.podman dependency will be automatically applied before this role.
Default values are defined in defaults/main.yml.
| Variable | Default | Description |
|---|---|---|
ivly_couchdb_image |
docker.io/couchdb:3.5.1 |
CouchDB container image |
ivly_couchdb_user |
admin |
CouchDB admin username |
ivly_couchdb_password |
changeme |
CouchDB admin password (override!) |
| Variable | Default | Description |
|---|---|---|
ivly_api_image |
docker.io/ivly/api:latest |
API container image |
ivly_backoffice_image |
docker.io/ivly/backoffice:latest |
Backoffice container image |
ivly_printer_image |
docker.io/ivly/printer:latest |
Printer container image |
ivly_validation_image |
docker.io/ivly/validation:latest |
Validation container image |
ivly_ollama_url |
(empty) | Remote Ollama endpoint (optional) |
ivly_ollama_model |
(empty) | Ollama model name (optional) |
ivly_app_locale |
de-DE |
Application locale |
| Variable | Default | Description |
|---|---|---|
ivly_caddy_image |
docker.io/caddy:2-alpine |
Caddy container image |
ivly_caddy_domain |
:80 |
:80 for plain HTTP, FQDN for Let's Encrypt |
ivly_caddy_tls_internal |
false |
Use Caddy's internal CA (self-signed) instead of Let's Encrypt - required for internal domains like *.fritz.box, *.local, etc. |
ivly_https_port |
8443 |
HTTPS port on host |
ivly_http_port |
8080 |
HTTP port on host |
| Variable | Default | Description |
|---|---|---|
ivly_podman_user |
podman |
Rootless Podman user |
ivly_autoupdate_enabled |
false |
Enable automatic image updates (podman-auto-update). Only meaningful with floating tags such as :latest, :1, :1.2. Pinned tags like :1.2.3 never change digest and would just waste bandwidth on the registry check. |
ivly_autoupdate_time |
04:00 |
Update schedule (systemd OnCalendar) |
ivly_autoupdate_random_delay |
900 |
Random delay in seconds |
| Variable | Default | Description |
|---|---|---|
ivly_custom_certs |
[] |
List of PEM file paths (on the Ansible controller) to trust inside ivly-api, e.g. an internal CA in front of the Ollama endpoint |
ivly_custom_certs_host_path |
/etc/ivly/extra-certs |
Where the certs are deposited on the host before being mounted into the container |
Sensitive values (e.g. ivly_couchdb_password) should be encrypted with Ansible Vault.
For deployments on internal networks (e.g. behind a Fritzbox using *.fritz.box domains, or *.local / *.lan), Let's Encrypt cannot issue certificates because the ACME servers cannot resolve or reach the domain from the public internet.
In that case, set ivly_caddy_tls_internal: true. Caddy will then act as its own CA and issue a self-signed certificate. The browser will show a security warning on first visit because the CA is not trusted by default.
To get rid of the browser warning, install Caddy's root certificate on each client machine.
The certificate is stored inside the rootless Caddy container's data volume. The podman user typically has /bin/nologin as its shell and a non-readable home directory, so a few workarounds are needed.
From your local workstation (replace <ivly-host> with the hostname):
ssh <ivly-host> "cd /tmp && sudo -u podman bash -c \
'XDG_RUNTIME_DIR=/run/user/\$(id -u) \
podman cp ivly-caddy:/data/caddy/pki/authorities/local/root.crt /tmp/caddy-root.crt' \
&& sudo chmod 644 /tmp/caddy-root.crt"
scp <ivly-host>:/tmp/caddy-root.crt ./caddy-root.crtNotes:
cd /tmpbeforesudoavoidscannot chdir to /home/<user>: Permission deniedwhen running as a regular user with a private home directory.chmod 644is required becausepodman cpwrites the file with mode0600, owned by thepodmanuser.
sudo security add-trusted-cert -d -r trustRoot \
-k /Library/Keychains/System.keychain caddy-root.crtOr via the GUI: open caddy-root.crt, then in Keychain Access find the Caddy Local Authority entry, double-click it, expand Trust, and set When using this certificate to Always Trust.
sudo cp caddy-root.crt /usr/local/share/ca-certificates/caddy-root.crt
sudo update-ca-certificatesFor Firefox (uses its own cert store): Settings -> Privacy & Security -> Certificates -> View Certificates -> Authorities -> Import, then enable Trust this CA to identify websites.
Double-click caddy-root.crt, then Install Certificate -> Local Machine -> Place all certificates in the following store -> Trusted Root Certification Authorities.
Mail the caddy-root.crt to your device, open it, then trust it via system settings. On iOS additionally enable full trust under Settings -> General -> About -> Certificate Trust Settings.
- Caddy regenerates the root CA only if the data volume is wiped. Once trusted, the certificate is valid for ~10 years.
- If you redeploy ivly and lose the Caddy data volume, you must redistribute the new root CA.
ivly-api must validate TLS connections to external services configured via ivly_ollama_url (and any future TLS endpoints). When that endpoint is fronted by a private CA - typical for an internal Ollama Caddy using tls internal, an enterprise PKI, or a self-signed certificate - the container's default trust store does not contain the issuer and requests fail with x509: certificate signed by unknown authority.
To make ivly-api trust additional CAs, set ivly_custom_certs to a list of PEM file paths on the Ansible controller:
- role: head1328.ivly
ivly_ollama_url: "https://ollama.internal/ollama"
ivly_custom_certs:
- "files/ca/internal-ollama-root.crt"The role then:
- Creates
ivly_custom_certs_host_pathon the host (default/etc/ivly/extra-certs). - Copies each listed PEM file into that directory.
- Mounts the directory read-only into
ivly-apiat/etc/ssl/certs/custom. - Sets
SSL_CERT_DIR=/etc/ssl/certs/customin the container.
Go (and OpenSSL-style libraries) treat SSL_CERT_DIR as a replacement for the default trust paths, not an addition. As soon as ivly_custom_certs is non-empty, ivly-api only trusts the certificates you list. This is fine as long as ivly-api only talks to:
- Internal services on the shared Podman network (CouchDB, Validator, Printer - all over plain HTTP).
- The configured Ollama URL.
If ivly-api ever needs to reach a public TLS endpoint (e.g. a hosted LLM, a webhook, a mail gateway), include the relevant public roots (/etc/ssl/certs/ca-certificates.crt from a Debian system, or the Mozilla bundle) in ivly_custom_certs as well.
This role uses Molecule for testing with Podman.
podmane.g. via Homebrewpython3e.g. via Homebrewpip install molecule "molecule-plugins[podman]"
# Full test suite (create, converge, idempotence, verify, destroy)
make test
# Development workflow
make converge # Apply the role
make verify # Run verification tests
make login # Login to test instance
make destroy # Clean up test instances
# Code quality
make lint # Run ansible-lintLink the role for local use without Galaxy:
make symlink
# Symlinks to ~/.ansible/roles/head1328.ivlyThis role uses Woodpecker CI for automated code quality checks on Codeberg. Linting with ansible-lint runs automatically on push and pull requests.
If ansible-ivly saved you a weekend - or you just like the idea of
self-hosted, container-based e-invoicing running quietly on a Raspberry
Pi - you can support continued maintenance via Liberapay. Donations go
directly into the time I spend on open-source work like this; one-off
or recurring, every bit helps.
This role is maintained by Kevin Horst. See the AUTHORS file for details.
AGPL-3.0-or-later