Homelab
Proof

U1 / Docker foundations

Portainer vs Dockge vs Komodo on one compose stack

Tested on Portainer CE 2.45.3

Test result

All three deployed the same compose, showed its logs and replaced a running image tag, so the choice is about what each costs and needs. Portainer CE was the smallest (27.6 MiB, one container), Dockge used 128.4 MiB and Komodo 242.9 MiB across three containers including MongoDB. Every one of them holds access to the Docker socket, which Docker's docs treat as root-level control of the host.

Line drawing: three different control panels with sliders and switches, each wired to the same group of shipping containers.

Pick the one whose footprint and architecture you can live with, because on the three jobs tested here they were equal: all of them deployed a compose, streamed logs, and replaced a container when the image tag in the compose changed.

The test stack and the three jobs

Each tool ran in its own VM and managed the same stack of two containers: web (nginx 1.28-alpine) and cache (redis 7.4-alpine). The script deployed it through the tool, then changed the web tag three times (1.29, 1.28, 1.29) and fetched logs. Images were pulled beforehand, so the times exclude downloads. The script drove each tool through the interface its web page uses (REST for Portainer and Komodo, socket.io events for Dockge) rather than by clicking, and timed from the request until docker inspect showed the new container running.

What each tool actually did in this test: deploy a compose, view logs, update an image
ToolDeploy compose (seconds to running)View logsUpdate image: seconds until the container runs the new tag, median (range) of 3Web image at the endHow it was driven
Portainer CE 2.45.3yes, 0.537 syes, 19 linesyes, 2.41 s (1.49-2.55)nginx:1.29-alpineREST API (stack create and update with pull)
Dockge 1.5.0yes, 0.987 syes, UI terminal panel (see screenshot)yes, 0.94 s (0.93-0.95)nginx:1.29-alpinesocket.io events the UI sends (deployStack)
Komodo 2.3.4yes, 1.498 syes, 28 linesyes, 2.58 s (2.37-2.93)nginx:1.29-alpineREST API (CreateStack, UpdateStack, DeployStack)

Images were pre-pulled, so times exclude downloads. Each time runs from the request until `docker inspect` shows the web container running with the expected image. The change went nginx 1.28 to 1.29 to 1.28 to 1.29, so every change replaced the container.

Dockge’s logs are the one entry that rests on a screenshot, not on a number: the web UI streams them into a terminal panel, and the screenshot below shows them there. The script’s own attempt to read that stream through the socket API returned nothing, so no line count exists for Dockge. The update times are not a ranking of the products, and the three were not timed doing identical work. Dockge replaced the container in about 0.94 s (0.93 to 0.95); Portainer took a median 2.41 s and Komodo 2.58 s, with ranges of 1.49 to 2.55 and 2.37 to 2.93. Portainer’s third change returned from the API in 0.01 s and finished in 1.49 s, against about 1 s of API time on its first two; why was not investigated. The script asked Portainer to pull the image on each update (pullImage: true); what Dockge and Komodo do about pulling was not checked. Dockge’s figure also includes starting a node process inside its container and logging in over socket.io for every call, which the REST calls to the other two do not include.

Dockge page for the stack demo, active, listing containers web on nginx 1.29-alpine and cache on redis 7.4-alpine, both running, with the compose.yaml and a terminal panel showing nginx log lines
Portainer Community Edition container list showing demo-cache-1 on redis 7.4-alpine and demo-web-1 on nginx 1.29-alpine, both running, with an Upgrade to Business Edition banner at the top left
Komodo stacks page listing one stack named demo, source UI Defined, host Local, state RUNNING

What each one needs

What each tool needs and what it used while managing the two-container test stack
ToolContainersRAM while managing (MiB, sum, median of 12)RAM per container (MiB)Containers that mount /var/run/docker.sockDatabase or data files found in its volumes
Portainer CE 2.45.3127.6portainer 27.6portainer (read-write)portainer.db
Dockge 1.5.01128.4dockge 128.4dockge (read-write)dockge.db
Komodo 2.3.43242.9mongo 186.9, core 49.5, periphery 6.5periphery (read-write)WiredTiger.wt

RAM was sampled 30 s after the last image change, with the two test containers running and the tool idle. VM 4 vCPU, 3 GB.

Komodo is the odd one: Core holds the interface and a MongoDB database, and a separate Periphery agent is the one that mounts the Docker socket (and, per the mount list in the raw output, the host’s /proc read-write and /etc/komodo). Its docs describe Periphery as a small stateless agent on each connected server, and Core as the web server hosting the API and UI, which is the design that lets one Core manage several hosts. This test used one host, so it paid for that design without using it. Portainer and Dockge are single containers with a local data file (portainer.db, dockge.db). The lab’s Komodo compose starts MongoDB with --wiredTigerCacheSizeGB 0.25, which caps its cache; with MongoDB’s default sizing its 186.9 MiB could be higher, and that was not measured. Dockge’s RAM samples also included a peak of 147.3 MiB (median 128.4).

For comparison, the lab’s separate idle measurements of these stacks, taken before any stack was deployed:

Idle footprint, measured on the reference test box. Median of 12 samples after settling.
StackVersionRAM at idleCPU at idleImages on diskReady after
Portainer CE2.45.318.2 MiB0.00%200.6 MB1.7 s
Dockge1.5.0122 MiB0.21%1168.8 MB3.7 s
Komodo2.3.4163 MiB0.78%2899.1 MB9.9 s

With a stack to manage, Portainer went from 18.2 to 27.6 MiB, Dockge from 121.7 to 128.4 MiB and Komodo from 162.7 to 242.9 MiB. The stacks were measured in different VMs and moments, so treat the increase as indicative; most of Komodo’s is its MongoDB container (186.9 MiB of the total), and its Periphery agent used only 6.5 MiB.

The Docker socket

All three mount /var/run/docker.sock read-write in the lab compose files (see the mounts column above; Komodo only in the Periphery container). Docker’s documentation says the daemon requires root privileges unless run rootless, that only trusted users should control it, and that a container started with the host’s / mounted can alter the host filesystem without restriction. Its post-install page says the docker group grants root-level privileges. The practical reading: whoever logs in to one of these tools, and anyone who can exploit a flaw in it, can start a container that mounts the host’s disk. This test did not check how each tool limits what its users may do, so it says nothing about the security of one over another. Keep the web port off the public internet; the lab binds it to 127.0.0.1.

Licences and editions

Taken from each project’s own pages. Portainer’s repository says Portainer is licensed under the zlib license; the company also sells Business Edition, and its pricing page lists a “Start free: 3 nodes” option whose terms the page does not describe. The CE interface in the screenshot shows an “Upgrade to Business Edition” banner. Dockge’s repository page lists an MIT license. Komodo’s introduction page says it is licensed under GPL-3.0 and mentions no paid edition. Check each project’s page for the current terms before you rely on any of this.

Which one (opinion)

If you want the least RAM and a single container, Portainer CE. If you want your stacks to stay as ordinary compose.yaml files in a folder you control, Dockge keeps them that way according to its README, and the test used that directory. If you expect to manage several hosts from one place and accept MongoDB, Komodo’s architecture is built for it, but this test used one host. This section is opinion; the tables above are what was measured.

Steps

The compose files are the lab’s stack files, unchanged (see lab/stacks/portainer, dockge and komodo). The script starts each tool, performs the first-run setup, and then deploys and updates the stack.

lab/tests/docker-ui/run.sh

This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.

Show the full file
#!/usr/bin/env bash
# docker-ui: Portainer CE 2.45.3, Dockge 1.5.0 and Komodo 2.3.4, each in its own VM, managing the same two-service compose.
# Deploys it, changes the web image tag three times, fetches logs, then records RAM, the Docker socket mounts and the data store.
# Usage: run.sh [portainer] [dockge] [komodo]
set -uo pipefail
source "$(dirname "$0")/../_dlib.sh"
T=docker-ui; OUT="$ROOT/evidence/tests/$T"; RAW="$OUT/raw"; D="$ROOT/lab/tests/$T"
TOOLS=("$@"); [ ${#TOOLS[@]} -eq 0 ] && TOOLS=(portainer dockge komodo)
[ $# -eq 0 ] && rm -rf "$OUT"; mkdir -p "$RAW"
declare -A PROJ=([portainer]=pt [dockge]=dk [komodo]=km)
declare -A NAMES=([portainer]="pt-portainer-1" [dockge]="dk-dockge-1" [komodo]="km-mongo-1 km-core-1 km-periphery-1")
one() {
  local tool="$1" p="${PROJ[$1]}"
  start_vm d-$T-$tool
  gx 'mkdir -p ~/t; sudo mkdir -p /opt/stacks /etc/komodo; sudo chown lab /opt/stacks'
  vm scp-to "$ROOT/lab/stacks/$tool/compose.yml" t/compose.yml >/dev/null
  vm scp-to "$D/uitest.py" uitest.py >/dev/null; vm scp-to "$D/dockge-drive.js" dockge-drive.js >/dev/null; vm scp-to "$ROOT/lab/tests/_dsample.py" sample.py >/dev/null
  dpull nginx:1.28-alpine nginx:1.29-alpine redis:7.4-alpine
  cpull '~/t' $p
  gx "cd ~/t && docker compose -p $p up -d 2>&1 | tail -1"
  case $tool in
    portainer) until gx 'curl -sf http://127.0.0.1:8080/api/system/status >/dev/null'; do sleep 2; done ;;
    dockge) until gx 'curl -sf -o /dev/null http://127.0.0.1:8080/'; do sleep 2; done; gx 'docker cp ~/dockge-drive.js dk-dockge-1:/tmp/d.js' ;;
    komodo) until gx 'curl -sf -o /dev/null http://127.0.0.1:8080/'; do sleep 2; done; sleep 40 ;;
  esac
  sleep 15
  gx "python3 ~/uitest.py $tool" > "$RAW/$tool-uitest.json" 2> "$RAW/$tool-uitest.err"
  sleep 30
  gx "python3 ~/sample.py ${NAMES[$tool]}" > "$RAW/$tool-ram-managing.json"
  gx "docker inspect -f '{{.Name}} {{range .Mounts}}[{{.Type}} {{.Source}} -> {{.Destination}} {{.Mode}}] {{end}}' \$(docker ps -q) | sort; echo; sudo sh -c \"for d in /var/lib/docker/volumes/${p}_*/_data; do echo \\\$d; ls -la \\\$d | head -12; done\"" > "$RAW/$tool-mounts-and-data.txt" 2>&1
  gx 'docker inspect -f "{{.Name}} restarts={{.RestartCount}}" $(docker ps -aq)' > "$RAW/$tool-restarts.txt" 2>&1
  local S="$ROOT/public/shots/$T"
  case $tool in
    portainer) shot 8080 / "$S/portainer.png" '[{"wait":6000},{"fill":"#username","value":"admin"},{"fill":"#password","value":"lab-only-not-a-secret"},{"press":"Enter"},{"wait":8000},{"goto":"http://127.0.0.1:18391/#!/1/docker/containers"},{"wait":8000}]' ;;
    dockge) shot 8080 / "$S/dockge.png" '[{"wait":4000},{"fill":"input[placeholder=Username]","value":"admin"},{"fill":"input[placeholder=Password]","value":"lab-only-not-a-secret"},{"press":"Enter"},{"wait":6000},{"click":"text=demo"},{"wait":10000},{"dump":".xterm-rows"}]' > "$RAW/dockge-ui-text.txt" 2>&1 ;;
    komodo) shot 8080 / "$S/komodo.png" '[{"wait":6000},{"fill":"input[placeholder=\"Enter username\"]","value":"admin"},{"fill":"input[placeholder=\"Enter password\"]","value":"lab-only-not-a-secret"},{"press":"Enter"},{"wait":8000},{"goto":"http://127.0.0.1:18391/stacks"},{"wait":8000}]' ;;
  esac
  mkdir -p "$RAW/$tool-env"; collect_env "$RAW/$tool-env"
}
for t in "${TOOLS[@]}"; do ( one "$t" ); done
python3 "$D/summarize.py" "$RAW" "$OUT"
lab/tests/docker-ui/uitest.py

This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.

Show the full file
#!/usr/bin/env python3
"""Runs in the VM. Usage: uitest.py portainer|dockge|komodo. Drives the tool the way its UI does (through the API it calls) to
deploy the same two-service compose (nginx web + redis cache), then change the web image tag three times, and fetch logs.
Times are from the request until `docker inspect` shows the web container running with the expected image (images pre-pulled)."""
import json, subprocess, sys, time, urllib.request, urllib.parse
tool = sys.argv[1]; PW = "lab-only-not-a-secret"; B = "http://127.0.0.1:8080"
def sh(c, check=True): return subprocess.run(c, shell=True, capture_output=True, text=True, check=check)
def yml(web): return f"services:\n  web:\n    image: {web}\n  cache:\n    image: redis:7.4-alpine\n"
def web_state():
    r = sh("docker inspect -f '{{.Config.Image}} {{.State.Status}} {{.Id}}' demo-web-1", False)
    if r.returncode: return None
    i, st, cid = r.stdout.split(); return i, st, cid
def wait_web(image, old_id, timeout=180):
    t = time.perf_counter()
    while time.perf_counter() - t < timeout:
        s = web_state()
        if s and s[0] == image and s[1] == "running" and s[2] != old_id: return time.perf_counter() - t, s[2]
        time.sleep(0.05)
    raise SystemExit(json.dumps({"error": f"web never reached {image}", "state": web_state()}))
def http(method, path, body=None, headers=None, raw=False):
    h = {"content-type": "application/json", **(headers or {})}
    try:
        r = urllib.request.urlopen(urllib.request.Request(B + path, data=json.dumps(body).encode() if body is not None else None, headers=h, method=method), timeout=180)
    except urllib.error.HTTPError as e:
        raise RuntimeError(f"HTTP {e.code} on {method} {path}: {e.read().decode()[:400]}")
    d = r.read(); return d if raw else (json.loads(d) if d else None)
out = {"tool": tool, "steps": []}
IMGS = ["nginx:1.28-alpine", "nginx:1.29-alpine", "nginx:1.28-alpine", "nginx:1.29-alpine"]  # first is the deploy, then three image changes
if tool == "portainer":
    tok = sh("docker logs pt-portainer-1 2>&1 | grep -o 'setup_token=[0-9a-f]*' | head -1 | cut -d= -f2").stdout.strip()
    http("POST", "/api/users/admin/init", {"Username": "admin", "Password": PW}, {"X-Setup-Token": tok})
    jwt = http("POST", "/api/auth", {"Username": "admin", "Password": PW})["jwt"]; A = {"Authorization": "Bearer " + jwt}
    r = urllib.request.Request(B + "/api/endpoints", data=b"--x\r\nContent-Disposition: form-data; name=\"Name\"\r\n\r\nlocal\r\n--x\r\nContent-Disposition: form-data; name=\"EndpointCreationType\"\r\n\r\n1\r\n--x--\r\n", headers={**A, "content-type": "multipart/form-data; boundary=x"}, method="POST")
    out["environment_created"] = json.load(urllib.request.urlopen(r))["Name"]
    def deploy(y, first):
        if first: return http("POST", "/api/stacks/create/standalone/string?endpointId=1", {"name": "demo", "stackFileContent": y, "env": []}, A)
        for _ in range(120):  # an update sent while the previous deploy is still finishing is refused with HTTP 409, so wait for status 1 (active)
            if http("GET", "/api/stacks/1", None, A)["Status"] == 1: break
            time.sleep(1)
        return http("PUT", "/api/stacks/1?endpointId=1", {"stackFileContent": y, "env": [], "prune": False, "pullImage": True}, A)
    def logs():
        cid = sh("docker ps -q --filter name=demo-web").stdout.strip()
        d = http("GET", f"/api/endpoints/1/docker/containers/{cid}/logs?stdout=1&stderr=1&tail=20", None, A, raw=True)
        return d.decode("utf-8", "replace")
elif tool == "komodo":
    jwt = http("POST", "/auth/login", {"type": "LoginLocalUser", "params": {"username": "admin", "password": PW}})["data"]["jwt"]; A = {"Authorization": jwt}
    def deploy(y, first):
        # an UpdateStack sent while the previous DeployStack is still running is refused ("Stack busy"), so wait for that update to complete first
        for _ in range(120):
            if not getattr(deploy, "pending", None): break
            if http("POST", "/read", {"type": "GetUpdate", "params": {"id": deploy.pending}}, A).get("status") == "Complete": break
            time.sleep(1)
        if first: http("POST", "/write", {"type": "CreateStack", "params": {"name": "demo", "config": {"server_id": "Local", "file_contents": y}}}, A)
        else: http("POST", "/write", {"type": "UpdateStack", "params": {"id": "demo", "config": {"file_contents": y}}}, A)
        u = http("POST", "/execute", {"type": "DeployStack", "params": {"stack": "demo"}}, A)
        deploy.pending = u["_id"]["$oid"]; return u
    def logs():
        return http("POST", "/read", {"type": "GetStackLog", "params": {"stack": "demo", "services": [], "tail": 20}}, A)["stdout"]
else:  # dockge: its socket API, through a node script inside the container
    sh("docker exec dk-dockge-1 node /tmp/d.js setup")
    def deploy(y, first):
        img = [l.split("image:")[1].strip() for l in y.splitlines() if "nginx" in l][0]
        return json.loads(sh(f"docker exec dk-dockge-1 node /tmp/d.js {'add' if first else 'deploy'} {img}").stdout)
    def logs(): return "(see the UI screenshot step: Dockge streams logs into a terminal panel through a pty)"
old = None
for n, img in enumerate(IMGS):
    t = time.perf_counter(); r = deploy(yml(img), n == 0); api_s = time.perf_counter() - t
    took, old = wait_web(img, old, 180); total = api_s + took
    out["steps"].append({"what": "deploy" if n == 0 else f"change web image to {img}", "image": img, "api_call_s": round(api_s, 3), "total_s_until_container_running": round(total, 3)})
time.sleep(5)
lg = logs(); out["logs_sample"] = lg[-700:]; out["log_lines_returned"] = len([l for l in lg.splitlines() if l.strip()])
out["web_image_final"] = sh("docker inspect -f '{{.Config.Image}}' demo-web-1").stdout.strip()
print(json.dumps(out, indent=1))
lab/tests/docker-ui/dockge-drive.js

This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.

Show the full file
// Runs inside the Dockge container (socket.io-client ships in /app/node_modules). Drives Dockge's socket API the way its web UI does.
// Usage: node dockge-drive.js setup | deploy <web-image> | add <web-image>
// "add" creates the stack (isAdd=true); "deploy" saves the edited compose file and redeploys it (isAdd=false), which is what Edit > Deploy does.
const { io } = require("/app/node_modules/socket.io-client");
const PW = "lab-only-not-a-secret";
const [step, image] = process.argv.slice(2);
const yaml = (web) => `services:\n  web:\n    image: ${web}\n  cache:\n    image: redis:7.4-alpine\n`;
const s = io("http://127.0.0.1:5001");
const call = (ev, ...a) => new Promise((res) => s.emit(ev, ...a, res));
s.on("connect", async () => {
  let r;
  try {
    if (step === "setup") r = await call("setup", "admin", PW);
    else {
      r = await call("login", { username: "admin", password: PW, token: "" });
      if (!r.ok) throw new Error("login: " + JSON.stringify(r));
      r = await call("agent", "", "deployStack", "demo", yaml(image), "", step === "add");
    }
    console.log(JSON.stringify({ step, image, r }));
  } catch (e) { console.log(JSON.stringify({ step, error: String(e) })); }
  process.exit(0);
});
setTimeout(() => { console.log(JSON.stringify({ step, error: "timeout" })); process.exit(1); }, 180000);

What went wrong

An earlier version of all these runs had the guest drop its page cache every 15 s, which raised the RAM readings; it was removed and the test rerun, and every test number on this page comes from the rerun (raw files dated 2026-11-02, 06:53 to 07:05 UTC). Two of the first attempts failed in ways that are worth knowing. Portainer refused a stack update sent right after the previous deploy with HTTP 409 until the stack’s status had returned to active, and Komodo refused an update with “Stack busy” (HTTP 500) while its previous DeployStack was still running. The final script waits for those to finish before the next change and does not count the wait. Portainer 2.45.3 also needs a setup token from its log (or the X-Setup-Token header) to create the first admin account, which the first draft of the script did not send. Dockge’s login page rendered blank at /login in the test browser although it worked at /; the screenshot uses /. The early failures were not kept as evidence.

What this test did not cover

Sources and how this was made

How this page was made: the commands above were run by an AI agent on the test box on November 2, 2026, and the same agent drafted this text from the saved output. The site's owner reviewed the site before publication. Test method and editorial policy explain how results are checked, and what is not. Spotted an error? Tell us.