An opinionated rootless Podman provisioning and deployment tool for containerized projects.
Deploy Git-backed, locally built container images in isolated rootless Podman
pods on remote hosts using Ansible. Images are pushed directly to the target
host, so no registry is required. The pod is defined as a Kubernetes Pod
manifest (kube.yaml) and run via podman kube play. depp generates the
systemd service unit from a bundled template.
- Rootless podman pods via
podman kube play(systemd-native) - Developer-authored
kube.yaml— depp generates the service unit - Direct image transfer, no container registry needed
.envfiles for secrets, rendered into a Kubernetes ConfigMap at runtime- Framework-agnostic application image built from the local project
- Project pod serves plain HTTP on
[app] listen_port(default 80), directly or through a Caddy sidecar; host Apache terminates TLS - Per-project Apache directives (IP allow-lists, HSTS, WebSockets) via an
optional
deploy/vhost.conf - Systemd integration via a generated service unit
- Backup and restore of named podman volumes
Requires Python 3.12 to 3.14 and Podman.
Install directly from GitHub, without cloning. Replace @v26.9.12 with another
tag, a branch, or a commit SHA to pin a different version:
pip install "git+https://github.com/tombreit/depp.git@v26.9.12"Or from a local clone of this repository:
pip install .
# For editable development installs:
pip install -e .depp depends on the batteries-included Ansible community package, which
supplies ansible-core and the collections used by the bundled playbooks.
Installation is larger than an ansible-core-only setup, but commands never
download Galaxy collections at runtime. depp uses only that Ansible and those
collections: another ansible-playbook on PATH, ~/.ansible/collections and
ANSIBLE_COLLECTIONS_PATH have no effect on it. No kubectl is required; the env
ConfigMap is rendered in pure Python.
depp uses calendar versioning, derived from the git tag at build time. Check what is installed with:
depp --versionA tagged release reports the tag alone, for example depp 26.9.12. Any other
build appends where it came from, so depp 26.9.12.1.dev3+gabc1234.d20260915
reads as:
| Part | Meaning |
|---|---|
26.9.12 |
last tag, cut 2026-09-12 |
.1 |
second release cut that day |
.dev3 |
3 commits after that tag |
+gabc1234 |
built from commit abc1234 |
.d20260915 |
built from a tree with uncommitted changes |
Everything depp needs lives in one deploy/ directory, so depp does not creep
into the project it ships:
myproject/
Containerfile
deploy/
depp.toml # deployment configuration (this is what depp reads)
kube.yaml # pod definition (dev + production)
.env # optional secrets (gitignored)
vhost.conf # optional Apache directives for this projectProjects that keep depp.toml at the project root next to deploy/, or in a
separate configuration repository, keep working; see
Project Layout.
[app]
# Required project slug; also the default image name.
name = "myapp"
# Optional. Defaults to ".." when this file lives in deploy/, else to its own
# directory. Relative to this file, or absolute.
# project_root = ".."
# image_name = "myapp"
# containerfile = "Dockerfile" # Defaults to Containerfile.
# The port the pod serves plain HTTP on (the app itself or a Caddy sidecar).
# listen_port = 80
[host]
# Required hostname: the Apache vhost, the certificate, the SSH target.
fqdn = "myapp.example.com"
# Required. A free 127.0.0.1 port on the host: the pod is published there and
# Apache proxies to it. Check with: ss -ltnp | grep 127.0.0.1
loopback_port = 8100
# Optional. The Linux account depp creates and deploys as; defaults to fqdn.
# user = "myapp"
# server_aliases = ["www.myapp.example.com"]
[acme]
contact_email = "admin@example.com" # Required for Apache mod_md.
# Defaults to Let's Encrypt staging.
certificate_authority = "https://acme-staging-v02.api.letsencrypt.org/directory"
# Secret; prefer the operator config instead of committing it here.
# external_account_binding = "kid hmac"There is no [backup] section: depp backs up every volume your kube.yaml
declares (see Backups).
depp loads this file into one immutable validated configuration model before a
command runs. Required tables, field types, paths, names, ports, health
settings, ACME email and URL values, and server aliases therefore fail with a
configuration error before Ansible starts. Defaults such as image_name,
containerfile, and health settings are resolved once and shared by summaries
and every inventory builder.
depp runs one app per Linux user. By default that user is the fqdn, which
keeps every name on the host (user, home, vhost, logs) derivable from one
value — but useradd caps names at 32 characters, so the fqdn inherits that
cap. Set [host] user to name the account yourself; the fqdn may then be any
lowercase DNS name up to 253 characters. Choose the user before the first
depp provision: depp records which account it provisioned for a host and
refuses to create a second one (see
Changing the deployment user). App and image
names must be lowercase DNS labels.
caddy_host_port is the 0.0.1 name of loopback_port. It is still accepted
with a warning, so a depp.toml written for an already-deployed app keeps
working unchanged; see Upgrading from 0.0.1.
[host] fqdn is the name depp provisions, gets a certificate for, and deploys as.
StrictHostCheck On means Apache answers only that name — any other Host:
gets a 403. If a friendlier public name should reach the app, list it under
server_aliases:
[host]
fqdn = "myapp.apps.example.com"
loopback_port = 8100
server_aliases = ["myapp.example.com"]depp then emits ServerAlias in both vhosts. The plain-HTTP redirect preserves
the requested hostname instead of bouncing visitors onto fqdn.
Two things worth knowing:
- Aliases are not added to the certificate. The managed domain pins
MDMembers manual, so mod_md requests a cert forfqdnonly. This is deliberate: an alias normally points at an upstream TLS-terminating proxy, so its DNS does not resolve to this host. ACME validation would therefore fail. - The upstream must preserve the Host header. If another proxy fronts the
alias, it must use
ProxyPreserveHost Oninstead of issuing a redirect.
This one-time operation needs sudo on the remote host:
depp provision # reads ./deploy/depp.toml, then ./depp.toml
depp provision --checkEvery command takes an explicit path instead (depp provision path/to/depp.toml).
SSH host keys are verified strictly by default. Before the first depp
connection, connect with ssh and verify the host key through your normal
trusted channel, or use --host-key-policy accept-new for explicit
trust-on-first-use. The latter records the first key and rejects later changes.
--host-key-policy insecure disables verification and should be reserved for
disposable test systems.
depp doctor
depp deploy
depp deploy --check
depp deploy -vRepeat step 5 for updates.
Deployments require a valid Git HEAD and a clean working tree. Clean releases
use the full commit SHA as their immutable image tag. --yes skips confirmation
only; it does not bypass this provenance check. For deliberate local testing,
--allow-dirty permits uncommitted source and assigns a unique
<commit>-dirty-<id> tag that cannot be confused with the clean commit.
The initial public scope is locally built project images. Direct deployment of stock third-party images without a local build project is not supported yet.
Internet → Apache (host, TLS) → 127.0.0.1:<loopback_port> → pod :<listen_port> (App, or Caddy → App)- Apache: Handles external traffic, TLS termination, and any per-project
access rules (on host). Project directives come from
deploy/vhost.conf. - Pod: Serves plain HTTP on
listen_port. An app that serves its own static files (a Go binary, Django with WhiteNoise) needs no sidecar; an app that wants a static-file server or several backends puts a Caddy container in front, as the bundled example does. - Deployments use dedicated non-privileged users; provisioning uses
become: true.
Multiple apps per host via distinct users/ports.
depp reads one directory: the one that holds kube.yaml, the optional .env
and the optional vhost.conf. Where that directory is follows from where
depp.toml lives — the file names themselves are fixed:
depp.toml location |
project_root default |
deploy files |
|---|---|---|
<project>/deploy/depp.toml (canonical) |
.. |
next to depp.toml |
<project>/depp.toml (0.0.1 layout) |
. |
<project>/deploy/ |
elsewhere, e.g. a config repo, with project_root set |
— | <project_root>/deploy/ |
The rule is keyed on the directory name deploy, not on the toml being
inside the project, so a configuration repository does not suddenly have the
files looked up next to its toml. Local backups always land in backups/ next
to depp.toml (deploy/backups/ in the canonical layout; gitignore it, or
deploy/ as a whole, since it sits beside .env).
project_root is the container build context. Keep deploy/ out of the image
with a .containerignore (a COPY . . would otherwise bake .env into a
layer):
.git
/deployA standard Kubernetes Pod manifest. The same file is used locally (with
podman kube play --publish) and in production (managed by the generated
service unit). Example:
apiVersion: v1
kind: Pod
metadata:
name: myapp
spec:
restartPolicy: Always
containers:
- name: caddy
image: docker.io/library/caddy:2-alpine
volumeMounts:
- name: private
mountPath: /app/data/private
- name: media
mountPath: /app/data/media
- name: app
image: localhost/myapp:latest
envFrom:
# Injects every key from your .env file. The ConfigMap is named
# "<image_name>-config" (image_name defaults to the app name) and is
# Generated from deploy/.env; reference it to receive the values.
- configMapRef:
name: myapp-config
volumeMounts:
- name: private
mountPath: /app/data/private
- name: media
mountPath: /app/data/media
volumes:
- name: private
persistentVolumeClaim:
claimName: myapp-private
- name: media
persistentVolumeClaim:
claimName: myapp-mediaVolume setup and naming are entirely up to you, the app author — declare
whatever volumes your app needs in kube.yaml. kube.yaml is depp's single
source of truth for volumes: it pre-creates every persistentVolumeClaim you
declare (reading the claimNames from the manifest) on deploy, and backs up the
same set (see Backups). There is no separate volume list to keep
in sync. The .env file is rendered into the myapp-config ConfigMap and
injected into the containers that reference it with envFrom (see above).
depp intentionally supports a small, predictable dotenv subset: one
KEY=value assignment per line, blank lines, # comment lines, and one
optional layer of matching single or double quotes around a value. Values may
contain additional = characters. Variable names must contain only letters,
digits, ., _, or -. Malformed lines, duplicate names, unmatched quotes,
empty files, and non-UTF-8 input abort deployment with the offending line
number instead of being silently ignored. Because this file normally contains
secrets, keep it out of version control and restrict it with chmod 600.
Before invoking Ansible, depp validates the local deployment inputs:
containerfilemust be a relative path to an existing regular file insideproject_root; it defaults toContainerfile.kube.yamlmust be valid UTF-8 YAML containing exactly one Pod.- The Pod needs a valid name and at least one regular container. Container, volume, PVC, and ConfigMap names must use lowercase Kubernetes name syntax.
- Container and volume names must be unique, and every
volumeMountmust refer to a declared volume. - No container may declare a
hostPort: depp publishes the pod on127.0.0.1:<loopback_port>so that Apache stays the only entry point, and ahostPortwould bind on all interfaces and bypass it (and TLS). - A regular or init container must reference
localhost/<image_name>:latest, the image built by depp. - When
deploy/.envexists, a regular or init container must reference the generated<image_name>-configConfigMap. Referencing that generated ConfigMap without an env file is also rejected.
The Pod must serve HTTP on [app] listen_port (default 80): the generated
unit publishes 127.0.0.1:<loopback_port>:<listen_port>. Kubernetes
containerPort declarations are optional metadata and do not prove which port
a process actually listens on, so depp documents but cannot preflight that
runtime requirement. Set listen_port to whatever the app binds — an
unprivileged port lets a non-root container serve without a sysctl or an
added capability.
When deploy/kube.yaml defines a Kubernetes httpGet readiness or liveness
probe, Podman translates that probe into a curl command executed inside the
probed container. The container image must therefore include curl; otherwise
every probe fails with curl: not found, and a liveness probe can repeatedly
restart an otherwise healthy container.
This does not apply to depp's post-deploy health gate described below. That
check runs from the host through the loopback port and does not require
curl in the application image.
- Reads the full Git commit SHA and rejects uncommitted source unless
--allow-dirtyis explicit - Builds the container image locally with
podman build, using the configuredcontainerfileor the defaultContainerfile. Passes the build argsVERSION(the image tag: the SHA, plus-dirty-<id>with--allow-dirty) andCOMMIT(the SHA)..gitis not in the build context, so a Containerfile that wants to know what it was built from declaresARG VERSIONandARG COMMIT. Do not use those two names for anything else: depp overrides them. - Saves it as an OCI archive (
.tar) - Transfers it to the remote host (rsync)
- Ensures the named volumes declared in
kube.yamlexist (podman volume) - Copies
kube.yaml→~/config/{name}/kube.yaml - Generates
{name}.servicefrom bundled template →~/.config/systemd/user/ - Renders
.env, when present, into~/config/{name}/configmap.yamland injects it with--configmap - Loads the image, tags it as
:latest - Reloads systemd and restarts the pod
- Polls the app over HTTP until it serves, then reports success (see Deployment downtime)
depp runs one pod per app, so a deploy is a stop-then-start: podman kube down tears down the old pod, then podman kube play starts the new one. There
is a brief window — typically around a second — where the pod is not serving.
This is not zero-downtime in the Kamal sense (which keeps the old version
running alongside the new during cutover); a single pod that bundles a database
cannot safely run two copies at once. depp instead keeps that window as small as
possible and makes it graceful:
- Image is preloaded. The image is built, transferred, loaded, and tagged
:latestbefore the pod is restarted, so no pull or transfer happens inside the window. - A clean "deployment in progress" page is served during the gap. Host Apache
(which stays up across deploys) returns a branded page with
Retry-After: 5viaErrorDocument 503instead of a raw proxy error, so browsers and uptime monitors see an intentional, retryable state. The page is provisioned to/var/www/depp/maintenance.html. - The deploy is health-gated. After the restart, depp polls
http://127.0.0.1:<loopback_port><health_path>until the app actually answers, and fails the deploy if it never comes up — so a crash-looping build is caught instead of being reported as a green deploy.
depp does not automatically roll back a failed release. It returns a non-zero status and leaves the attempted deployment in place for diagnosis.
Tune the health gate with an optional [deploy] section in depp.toml (both
fields are optional and shown here with their defaults):
[deploy]
health_path = "/" # URL path polled on the host loopback port
health_timeout = 30 # seconds to keep polling before failing the deployKeep the cutover short: do per-deploy work at build time, not pod start. The
biggest avoidable cost is work that runs every time the pod starts. For example,
collect static assets into the image in your Containerfile so an initContainer
only has to copy the pre-built files into a shared volume, instead of running a
full collectstatic on every restart — see
examples/wagtail-postgres. Likewise, run database
migrations as an explicit step (depp exec … -- python manage.py migrate) rather
than blocking pod startup on them.
- Installs podman on the remote host
- Creates the deployment user (
[host] user, default: the fqdn). The generated vhost records it (Define DEPP_USER), and a later run naming a different user is refused - Enables systemd lingering
- Installs Apache, ensures it is running, and writes the vhost for
fqdn - Installs the project's
deploy/vhost.conf, when present, to/etc/apache2/depp/<fqdn>.vhost.conf(and removes it when the project no longer ships one)
Apache is only reloaded after apachectl configtest passes, so a broken
vhost or snippet fails the provisioning run with Apache's own error and leaves
the running configuration untouched.
depp's vhost is deliberately generic: TLS via mod_md, an HTTPS redirect, a
maintenance page during deploys, and a catch-all ProxyPass to the pod. What
a project needs beyond that goes into deploy/vhost.conf, a plain Apache
fragment that depp provision installs verbatim and includes from inside the
<VirtualHost *:443> block:
# deploy/vhost.conf — spliced into <VirtualHost *:443> by depp
# Only trusted networks may reach the admin area.
<LocationMatch "^/manage(/|$)">
Require ip 192.0.2.0/24 2001:db8::/32
</LocationMatch>
# Pin HTTPS in browsers (depp's own MDRequireHttps is "temporary").
Header always set Strict-Transport-Security "max-age=31536000"
# WebSockets: upgrade one path (Apache ≥ 2.4.47), leave the rest as HTTP.
ProxyPass /ws ${DEPP_BACKEND}/ws upgrade=websocket
# Restate any of depp's defaults to override them: last directive wins.
Protocols http/1.1
LogLevel warnThe fragment sits after depp's own directives and before the catch-all
ProxyPass /. Two consequences follow: a directive you restate (Protocols,
LogLevel, a RequestHeader) replaces depp's, and a ProxyPass you add is
matched first, because Apache matches ProxyPass prefixes in order. depp
defines five variables the fragment may use:
| Variable | Value |
|---|---|
${DEPP_FQDN} |
[host] fqdn |
${DEPP_APP} |
[app] name |
${DEPP_USER} |
the deployment user ([host] user, default: the fqdn) |
${DEPP_LOOPBACK_PORT} |
[host] loopback_port |
${DEPP_BACKEND} |
http://127.0.0.1:<loopback_port> |
Things to know:
- It is root-parsed Apache configuration. An
Include, aCustomLog "|…"pipe or aScriptAliasin it runs with Apache's privileges. Treat the file with the trust you give thesudostep that installs it. It is applied only bydepp provision(elevated, confirmed), never bydepp deploy, so a change to it cannot ride along the unprivileged deploy path. - Only the
:443vhost. The:80vhost exists to redirect to HTTPS and to answer ACME challenges; there is no hook for it on purpose. - depp validates only what Apache would accept but cannot mean here: the
file must not open its own
<VirtualHost>or<MDomainSet>. Everything else is checked byapachectl configteston the host, before the reload.--checkcannot run that test. Require ipsees the address Apache sees. For aserver_aliasthat an upstream proxy forwards here, that is the proxy's address, not the visitor's. depp does not configuremod_remoteip; addRemoteIPHeader/RemoteIPTrustedProxyto the fragment yourself if you need client addresses behind another proxy.- The installed copy is at
/etc/apache2/depp/<fqdn>.vhost.conf(root, 0640) for inspection;apachectl -Sshows the vhost that includes it.
Everything the pod owns — the podman storage and named volumes, the systemd
units, ~/config/<app> — lives in the deployment user's home. Changing
[host] user on a host that is already provisioned is therefore a migration,
not a setting, and depp provision refuses it. To migrate by hand, as root,
with the service stopped (systemctl --user stop <app> as the old user):
usermod -l NEW -d /home/NEW -m OLD, loginctl disable-linger OLD,
loginctl enable-linger NEW, change the Define DEPP_USER line in
/etc/apache2/sites-available/<fqdn>.conf to NEW (or delete that file), then
run depp provision and depp deploy. Hosts provisioned by 0.0.1 have no
such line and are recognised by their fqdn-named user instead; they are
refused the same way.
Runs non-mutating deployment-readiness checks:
- validates
depp.toml, the Containerfile,deploy/kube.yaml, and.env; - checks Git HEAD and working-tree state;
- checks required control-node executables, Ansible 14 or newer, and every collection module used by the bundled playbooks;
- connects through the configured connection and host-key policy;
- verifies target rsync, rootless Podman, and the user systemd manager.
Run doctor after provisioning and before the first deployment:
depp doctorA dirty working tree makes doctor return status 1 because a normal deployment
would be rejected. Use --allow-dirty when the dirty source is intentional.
Doctor does not install dependencies, alter the project, or change target
state.
Note: Earlier depp versions restricted SSH logins on the host to an
ssh_allowed_deppgroup via an sshdAllowGroupsdrop-in. This is no longer done. To lift the restriction on a host provisioned back then, run (as root):rm /etc/ssh/sshd_config.d/allow_groups_depp.conf && systemctl reload ssh— remove the drop-in first; deleting the group whileAllowGroupsis active would deny all SSH logins. Afterwards the unused group can be removed withgroupdel ssh_allowed_depp.
Secrets like the ACME External Account Binding (EAB kid + hmac) belong to the
operator, not to the target project — they authenticate you to the CA
and are only used by depp provision. Keep them out of the project repo and
put them in a per-user operator config instead:
# ~/.config/depp.toml (chmod 600 — it holds secrets)
[acme]
external_account_binding = "kid hmac"
certificate_authority = "https://acme-v02.api.letsencrypt.org/directory"The [acme] keys are merged over the project depp.toml's [acme] section
(the operator value wins) and validated like the project's own: unknown keys
are rejected. The file is loaded automatically when it exists
($XDG_CONFIG_HOME respected); use --secrets FILE on provision to point
elsewhere.
Fetches the contents of every persistentVolumeClaim declared in your
kube.yaml to a timestamped local directory — no configuration needed.
Ephemeral volumes (emptyDir, configMap) are not PVCs, so they are excluded
automatically. The pod must be running. Volume data is accessed via the host-side
mountpoint — no temporary files or podman cp required.
depp backup [-v]The remote side of the copy runs under podman unshare, inside the rootless
user namespace. That matters when the image runs as a non-root user
(USER 65532 in the Containerfile, say): rootless podman stores what that
user writes under one of the deploy user's subuids, and the deploy user
cannot read a 0600 file or enter a 0700 directory owned by a subuid.
Inside the namespace it is root and reads everything. The local copy is
written by your own unprivileged user, so modes and timestamps survive but
ownership does not. With --connection local the copy is a plain local
rsync (no remote side), so there the volume has to be readable by the
current user.
Backups are saved next to depp.toml (so under deploy/ in the canonical
layout):
backups/
myapp.example.com/
2026-03-14T103045Z/ ← UTC timestamp
myapp-private/ ← one subdir per PVC in kube.yaml
myapp-media/Database caveat: backup copies the live volume mountpoint with
rsync. For a running database, the on-disk data directory is not guaranteed to be crash-consistent. For databases prefer a logical dump into a backed-up volume, e.g.depp exec -- pg_dump … > /app/data/media/dump.sql.
Pushes local files back into the named podman volumes on the remote host. Always takes a safety backup first — the restore is aborted if the backup fails. The pod is stopped during the transfer and restarted afterwards.
depp restore [DEPLOY_TOML] RESTORE_PATH [-v] [--yes]RESTORE_PATH is a local directory whose subdirectory names must match the
PVC names declared in kube.yaml. This is the same structure that
depp backup produces:
RESTORE_PATH/
myapp-private/ ← subdirectory name == claimName from kube.yaml
somefile.db
myapp-media/ ← subdirectory name == claimName from kube.yaml
logo.pngRestores are additive by default — existing files not present in the source
are left in place. Only the volumes whose subdirectory exists under RESTORE_PATH
are restored; missing volume directories produce a warning and are skipped, not
an error. This makes it straightforward to restore a single file or a single
volume:
my-partial-restore/
myapp-private/ ← only this volume is restored; myapp-media is skipped
path/to/single-fileFile permissions and timestamps are preserved (rsync --archive). Ownership is
not taken from the backup, which carries your workstation's uid: the remote
rsync runs under podman unshare (see depp backup) and sets every restored
file to the volume's owner as podman volume inspect reports it — the uid the
container runs as for a volume seeded from an image directory, otherwise root
of the namespace, which is the deploy user.
Empties every PVC volume of the pod without deleting the volumes themselves.
It takes a safety backup first (aborting if that fails), lists what it is
about to delete with container-internal paths, and asks for confirmation. The
deletion runs under podman unshare like backup and restore, so files the
container wrote as a non-root uid are removed too. The pod stays running;
restart it afterwards (a depp deploy does) so the application starts from
the empty volume.
depp reset [DEPLOY_TOML] [-v] [--yes]Runs a command inside the running application container on the remote host
via SSH. If no command is given, opens an interactive shell (/bin/sh in
the container, or a login shell with --host). Uses podman exec under
the hood for container access.
The target container is resolved from kube.yaml: the pod's only container,
or the one named app when there are several. Pick a different one with
--container NAME (the name as written in kube.yaml). Put -- before the
command; when DEPLOY_TOML is omitted that is what tells depp where the
command starts.
depp exec # interactive shell in container
depp exec -- ls -la /app # run command in container
depp exec --container worker -- ps ax
depp exec --host # login shell on remote host
depp exec --host -- systemctl --user status # run command on host
cat local-dump.sql | depp exec -- psql -U app appdb
depp exec path/to/depp.toml -- ls # explicit configuration fileA TTY is allocated whenever both stdin and stdout are terminals, on the SSH
hop and in podman exec alike. So prompting commands work as they do locally,
while pipes and redirects still get clean, un-mangled byte streams (no pty
echo, no CRLF translation). Creating a Django superuser in the running app
container is just:
depp exec -- python manage.py createsuperuserOverride the detection with -t/--tty (force allocation) or -T/--no-tty
(never allocate). In CI and other non-TTY contexts, use the command's own
non-interactive mode:
depp exec -- env DJANGO_SUPERUSER_PASSWORD=changeme \
python manage.py createsuperuser --noinput --username admin --email admin@example.comdepp provision [DEPLOY_TOML] [options]
depp deploy [DEPLOY_TOML] [options]
depp backup [DEPLOY_TOML] [options]
depp restore [DEPLOY_TOML] RESTORE_PATH [options]
depp reset [DEPLOY_TOML] [options]
depp doctor [DEPLOY_TOML] [options]
depp exec [DEPLOY_TOML] [options] [-- COMMAND ...]DEPLOY_TOML defaults to ./deploy/depp.toml, then ./depp.toml; without
either, depp exits with status 1. Run depp COMMAND --help for
command-specific options. --host-key-policy accepts strict (default),
accept-new, or insecure.
Commands use --connection ssh by default. For testing against the current
machine, every command also accepts --connection local:
depp deploy --connection local --yes
depp exec --connection local -- python manage.py checkLocal mode keeps the configured FQDN as the Ansible inventory and application
identity, but executes as the current local user. It does not use SSH, the depp
SSH key, or host-key policy. Image transfer and loading are skipped because the
locally built image is already available to local Podman. depp exec invokes
Podman directly; depp exec --host invokes the requested command directly.
Use local provisioning carefully: depp provision --connection local runs the
provisioning playbook against the current machine and can make system-wide
changes through privilege escalation. Local mode is intended for disposable
or dedicated Debian 13 test hosts, not for pretending a workstation is a remote
production target.
provision, deploy, restore, and reset ask for confirmation when run
interactively, except for check mode. In CI or any environment without an
interactive stdin, pass --yes; otherwise depp refuses before starting command
work. --yes only answers the confirmation prompt. It does not disable SSH
verification or permit a dirty Git working tree. Use the separate explicit
options for those choices.
depp uses these process exit statuses:
| Status | Meaning |
|---|---|
0 |
Operation succeeded, check completed, or the user explicitly cancelled |
1 |
Configuration, validation, connection, or operation failed |
2 |
Command-line usage error reported by argparse |
130 |
Interrupted with Ctrl-C |
An app deployed with 0.0.1 keeps deploying with its depp.toml, kube.yaml
and .env untouched. UPGRADING.md lists the few things that
do need action: the one breaking change, the deprecated key, and what a
re-provision changes on the host.
- Python 3.12 to 3.14
- Ansible (installed automatically as a Python dependency of depp)
- Podman
- Git
- rsync
- Debian 13 Trixie
- Rootless podman 5.4+
- Apache 2
- Systemd (user scope)
- SSH access when using
--connection ssh
After deployment, files are placed as:
~/.config/systemd/user/ # Generated service unit
myapp.service
~/config/myapp/ # App config
kube.yaml # Copied from deploy/kube.yaml
configmap.yaml # Rendered from deploy/.env, injected via --configmap
podman volume (myapp-private)/ # A named volume declared in kube.yaml
podman volume (myapp-media)/ # Another named volume declared in kube.yamlProvisioning (as root) additionally writes:
/etc/apache2/sites-available/<fqdn>.conf # Generated vhost (enabled via sites-enabled)
/etc/apache2/depp/<fqdn>.vhost.conf # Copy of deploy/vhost.conf, when present
/var/www/depp/maintenance.html # Page served while the pod restartskube.yamlauthored by developer: Pod definition lives in the project repo- Service unit generated by depp: Single source of truth for the
podman kube playinvocation - No registry: Direct image transfer via rsync
- Rootless: Everything runs under a non-privileged user
- Standard formats: Kubernetes Pod manifest, ConfigMap, and
.env - Ansible-backed: Uses standard modules from the Ansible community package, not custom deployment scripts
git clone https://github.com/tombreit/depp.git
cd depp
python -m venv .venv && .venv/bin/pip install -e ".[dev]"
.venv/bin/pre-commit install # git hooks: lint on commit, pytest on push
.venv/bin/pytest # test suite; no network, no host required
.venv/bin/ruff check . # lint
.venv/bin/ansible-lint # playbooks, production profile
.venv/bin/tox # everything above, on each supported PythonRun pre-commit install again after a change to the hook types in
.pre-commit-config.yaml.
tox builds the package and tests the result, so it also notices a file missing
from the package data. It uses the Python versions already installed and skips
the others. With a umask of 0002, it refuses to start because it finds its own
.tox directory group-writable. Create that directory once with
(umask 022; .venv/bin/tox), after removing any .tox left behind; later runs
work under the normal umask.
tests/expected/ holds the rendered systemd unit and Apache vhost for a
project that uses no newer option. A template change that alters what an
existing host gets shows up there as a reviewable diff; update the expected
file only when that change is intended.
pytest also runs the real ansible-playbook --syntax-check on every bundled
playbook (tests/test_playbooks.py), so a broken playbook, or an Ansible that
cannot start, fails without a target host.
CI (.github/workflows/ci.yml) runs tox on Python 3.12, 3.13 and 3.14 on every
push and pull request. The versions are listed explicitly because a new Python
can break Ansible on release day. Adding one means raising requires-python,
env_list and the CI matrix together, once the ansible dependency supports it.
depp builds every subprocess invocation as an argument list — there is no
shell=True, os.system, or eval anywhere — and passes .env-derived
secrets to Ansible through a 0600 inventory file rather than on the command
line, so they never appear in ps output or shell history. SSH host key
checking is strict by default, and become is used only during provisioning.
If you find a security issue, please report it privately to mail@thms.de rather than opening a public issue.
Copyright (c) 2026 Thomas Breitner
Licensed under the EUPL-1.2-or-later. See LICENSE for the full text.