podman-deploy
October 4, 2025
There is time to solve another thing in my journey of self hosting things. Whenever i want to deploy a new app i have to run the same commands and im getting tired. I want something like Kamal or Dokploy. I want to run some command and have everything done. Thats why i wrote the following scripts tuned to my own use case, podman + caddy.
Auto starting apps
Podman has service called podman-restart that will start all containers that where created with --restart=always which isnt enabled by default at least on debian. So i only have to enable it with systemctl --user enable podman-restart.service
Structure
I have the following structure for caddy.
A main Caddyfile that sets some global configs for each domain and an import for its index page and subapps. Socket activation so that apps can get the real user ip and not the one from the caddy pod.
{
servers {
trusted_proxies static 10.0.0.0/8
}
}
domain {
log {
output discard
}
bind fd/3 {
protocols h1
}
bind fd/4 {
protocols h1 h2
}
bind fdgram/5 {
protocols h3
}
import sites-enabled/domain.caddy
}
To enable socket activation, you also have to create a container-caddy.socket systemctl file with the following content
[Socket]
ListenStream=[::]:80
ListenStream=[::]:443
ListenDatagram=[::]:443
BindIPv6Only=both
[Install]
WantedBy=sockets.target
Then in my sites-enabled/domain.caddy file
import domain/freshrss/freshrss.caddy
import domain/app/app.caddy
import domain/index.caddy
On sites-enabled/domain/index.caddy the configuration for my index page, and on each sites-enabled/domain/app/app.caddy
@appname path /appname /appname/*
route @appname {
forward_auth authelia:9091 {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
}
uri strip_prefix /appname
reverse_proxy appname:8080
}
I also have a sites-enabled/domain/app/log folder for each pod logs, and a sites-enabled/domain/app/deploy.toml file with deploy configs
healthcheck_url = "http://127.0.0.1:8080"
healthcheck_interval = 5
max_healthchecks = 5
entrypoint = ["/usr/bin/app", "arg1"]
volumes = ["app_data:/data"]
[env]
HTTP_ROOT_PATH="/app"
healthcheck_url and the likes are used to do zero downtime deploys. It will spin up the container, whenever this url returns a 200 the new pod is added to the network and the old one is brought down. The other fields i think are self explainatory.
Creating apps
If you think the previous structure its a lot to create manually whenever you want to create a new app, you are correct, thats why i created the following script for that. I dont tend to write python so maybe there are a few things that arent the python way, but it works for now. You run it like podman-app-create domain/appname
#!/usr/bin/python3
import sys
from os import path
import os
import subprocess
import settings
def create_app(domain, app):
domain_path = path.join(settings.ROOT, "sites-enabled", domain)
domain_caddy_file_path = path.join(settings.ROOT, "sites-enabled", f"{domain}.caddy")
app_path = path.join(domain_path, app)
log_path = path.join(app_path, "log")
if not path.exists(domain_path):
print(f"Domain '{domain}' does not exists")
return
if path.exists(app_path):
print(f"App '{app}' already exists under domain '{domain}'" )
return
os.makedirs(app_path, exist_ok=True)
os.makedirs(log_path, exist_ok=True)
deploy_file_path = path.join(app_path, f"deploy.toml" )
deploy_file = open(deploy_file_path, "w")
deploy_file.write(f"""healthcheck_url = "http://127.0.0.1/up"
healthcheck_interval = 5
max_healthchecks = 5
entrypoint = []
volumes = []
[env]
""")
deploy_file.close()
app_caddy_file_path = path.join(app_path, f"{app}.caddy" )
app_caddy_file = open(app_caddy_file_path, "w")
app_caddy_file.write(f"""@{app} path /{app} /{app}/*
route @{app} {{
forward_auth authelia:9091 {{
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
}}
uri strip_prefix /{app}
reverse_proxy {app}:8080
}}""")
app_caddy_file.close()
original_data = ""
original_file = open(domain_caddy_file_path, 'r')
original_data = original_file.read()
original_file.close()
modified_file = open(domain_caddy_file_path, 'w')
modified_file.write(f"import {domain}/{app}/{app}.caddy\n{original_data}")
modified_file.close()
subprocess.run(["podman", "exec", "-w", "/etc/caddy", "caddy", "caddy", "reload"])
def main():
if len(sys.argv) < 2:
print("Correct syntaxis is podman-create-app domain/app-name")
return
args = [x.strip() for x in sys.argv[1].split("/")]
if len(args) != 2 or args[0] == "" or args[1] == "":
print("Correct syntaxis is podman-create-app domain/app-name")
return
create_app(args[0], args[1])
if __name__ == "__main__":
main()
Deploying apps
And whenever you want to deploy an app without downtime you can run the following one podman-app-deploy domain/appname image-identifier
#!/usr/bin/python3
import sys
import os
import subprocess
import random
import string
import time
import tomllib
import json
from os import path
import settings as global_settings
def parse_settings(domain, app):
settings = {
'container_name': '',
'log_file': '',
'env': {},
'app_path': '',
'healthcheck_url': 'http://127.0.0.1/up',
'volumes': [],
'entrypoint': [],
'healthcheck_interval': 5,
'max_healthchecks': 5,
}
domain_path = path.join(global_settings.ROOT, "sites-enabled", domain)
settings['app_path'] = path.join(domain_path, app)
log_path = path.join(settings['app_path'], "log")
if not path.exists(domain_path):
raise Exception(f"Domain '{domain}' does not exists")
if not path.exists(settings['app_path']):
raise Exception(f"App '{app}' does not exists under domain '{domain}'" )
deploy_settings_path = path.join(settings['app_path'], "deploy.toml")
with open(deploy_settings_path, "rb") as f:
deploy_settings = tomllib.load(f)
settings['healthcheck_url'] = deploy_settings['healthcheck_url']
settings['healthcheck_interval'] = deploy_settings['healthcheck_interval']
settings['max_healthchecks'] = deploy_settings['max_healthchecks']
if len(deploy_settings['entrypoint']) > 0:
settings['entrypoint'] = json.dumps(deploy_settings['entrypoint'])
settings['env'] = deploy_settings['env']
settings['volumes'] = deploy_settings['volumes']
random_string = ''.join(random.choices(string.ascii_lowercase + string.digits, k=6))
settings['container_name'] = f"{app}-{random_string}"
settings['log_file'] = path.join(log_path, settings['container_name'])
return settings
def create_container(settings):
create_args = [
"podman",
"run", "-d",
"--network", "podman",
"--log-opt", f"path={settings['log_file']}",
"--name", settings['container_name'],
"--restart", "always"]
for v in settings['volumes']:
create_args.append("-v")
create_args.append(v)
for key,value in settings['env'].items():
create_args.append("-e")
create_args.append(f"{key}={value}")
if len(settings['entrypoint']) > 0:
create_args.append("--entrypoint")
create_args.append(settings['entrypoint'])
create_args.append(settings['image'])
subprocess.run(create_args, stdout = subprocess.DEVNULL, cwd=settings['app_path'])
pid = subprocess.run(["podman", "inspect", settings['container_name'], "-f", '{{.State.Pid}}'], capture_output=True)
if pid.returncode != 0:
raise Exception(f"Could not get pid of new container {pid.stderr}")
return
print(f"Waiting for container to be online...")
is_up = False
attempt = 0
while not is_up and attempt < settings['max_healthchecks']:
print(f"Waiting {settings['healthcheck_interval']} seconds")
time.sleep(settings['healthcheck_interval'])
print(f"Attempt {attempt + 1} to {settings['healthcheck_url']}")
status = subprocess.run(["nsenter", "-t", pid.stdout.strip(), "-U", "-n", "curl", settings['healthcheck_url'], "-s", "-o", "/dev/null", "-w", "%{http_code}"], capture_output=True)
is_up = status.returncode == 0 and (status.stdout == b"200" or status.stdout == b"404")
attempt += 1
if attempt == settings['max_healthchecks']:
print(f"Container is created but not connected to the network")
print(f"When ready run `podman network connect --alias {settings['app']} {global_settings.MAIN_NETWORK} {settings['container_name']}` to connect it")
print(f"Current pods still alive")
raise Exception(f"Failed to check health after {attempt} attempts")
else:
subprocess.run(["podman", "network", "connect", "--alias", settings['app'], global_settings.MAIN_NETWORK, settings['container_name']])
def remove_old_containers(settings):
old_ids = subprocess.run(["podman", "ps", "--no-trunc", "--filter", "status=running", "--filter", f"name={settings['app']}-.*", "--format", "{{.Names}}"], capture_output=True, text=True)
if old_ids.returncode != 0:
raise Exception(f"Could not get ids of old containers {old_ids.stderr}")
return
for old_name in [str(name.strip()) for name in old_ids.stdout.splitlines()]:
if old_name != settings['container_name']:
subprocess.run(["podman", "stop", old_name], stdout = subprocess.DEVNULL, stderr = subprocess.DEVNULL)
print(f"Removed old container '{old_name}'")
def deploy_app(domain, app, image):
try:
settings = parse_settings(domain,app)
settings['image'] = image
settings['app'] = app
print(f"Creating container for '{domain}/{app}")
create_container(settings)
print(f"Removing old containers...")
remove_old_containers(settings)
except Exception as e:
print(e)
return
def main():
if len(sys.argv) < 3:
print("Correct syntaxis is podman-deploy-app domain/app-name image-identifier")
return
args = [x.strip() for x in sys.argv[1].split("/")]
if len(args) != 2 or args[0] == "" or args[1] == "":
print("Correct syntaxis is podman-deploy-app domain/app-name image-identifier")
return
deploy_app(args[0], args[1], sys.argv[2].strip())
if __name__ == "__main__":
main()
Outro
Now i have what i think its a good secure server, with reverse proxy and zero downtime deploys with autostart. The only thing missing its some kind of ci to deploy/build whenever i push to the repository, but i think this could be implemented easily with git hooks. For now, time to do another thing.

Leave your comment on the github issue, sending me an email or DMing me on twitter