Skip to content
Beau Barker edited this page Aug 7, 2025 · 47 revisions

CGI allows you to build lightweight endpoints in Bash (or any language with a shebang), without needing a full application server. It’s useful for tasks like returning dynamic JSON, invoking CLI tools, or integrating with your local filesystem.

Note

CGI is simple and powerful for many tasks, but not efficient for high-frequency workloads.

We'll use caddy-cgi. If you're OK with Caddy handling the response (200 every time), use Exec instead.

Caddy

Make a directory for scripts:

mkdir caddy/scripts

Build the Caddy image with the caddy-cgi module, copying our scripts into the image:

caddy/Dockerfile

FROM caddy:2-builder AS builder

RUN xcaddy build \
    --with github.com/aksdb/caddy-cgi/v2

# Final lightweight image
FROM caddy:2

COPY --from=builder /usr/bin/caddy /usr/bin/caddy

# Copy our scripts into the image
COPY scripts /usr/local/cgi-bin

# Copy our Caddyfile into the image
COPY Caddyfile /etc /caddy/Caddyfile

If you want to add extras (like bash, jq, curl or a scripting language such as Python), add a line such as:

RUN apk add --no-cache bash jq curl

Build the image:

docker compose build caddy

Mount the Scripts

Mount the scripts directory in the Compose override file (which affects development only):

compose.override.yaml

caddy:
  volumes:
    - ./caddy/scripts:/usr/local/cgi-bin:ro

Add a Route

caddy/Caddyfile

cgi /path /usr/local/cgi-bin/myscript.sh arg1 arg2

Make sure scripts are executable.

chmod +x caddy/scripts/myscript.sh

Restart Caddy

Lastly, recreate the Caddy container:

docker compose up -d --force-recreate caddy

Writing Scripts

Output goes to:

  • stdout goes to the response.
  • stderr goes to Caddy's logs.

The stdout output must include headers:

caddy/scripts/myscript.sh

#!/bin/sh

echo "Content-Type: text/plain"
echo
echo 'Hello'

Error Handling

cgi will respond with HTTP status 200 even if the script fails, and the body will be whatever had been output up to the point of failure.

If you want script failures to respond with 500 Internal Server Error, and the details logged, use this wrapper script:

caddy/scripts/entry.sh

Click to expand
#!/bin/sh
set -euo pipefail

TMP=$(mktemp)
trap 'rm -f "$TMP"' EXIT

handle_error() {
  code=$?
  echo "Error $code in $1 on line $2" >&2

  echo "Status: 500 Internal Server Error"
  echo "Content-Type: text/plain"
  echo
  echo "Internal Server Error"

  exit 0
}
TARGET=$1
shift
trap 'handle_error "$TARGET" $LINENO' ERR

# --- Source logic script in subshell, redirecting output ---
(
  source "$TARGET" "$@"
) >"$TMP"

# --- Only reached on success ---
cat "$TMP"

And update your route:

cgi /my_route /usr/local/bin/entry.sh myscript.sh

The entry.sh script should be executable, but the scripts it runs don't need to be.

chmod +x caddy/scripts/entry.sh

If your myscript.sh is bash, the entry.sh should also use bash.

Clone this wiki locally