A recurring conversion job is one of the most common automation requests: nightly export of reports to PDF, daily normalization of uploaded images to WebP, weekly archival of logs to compressed formats. The pattern is simple but the details matter — proper logging, failure alerting, and idempotency are what separate a script that works from one you trust.
TL;DR
Three components: a bash script that converts files and exits non-zero on any failure, a logrotate config that keeps logs manageable, and a crontab entry that captures both stdout and stderr. Alerting is handled by checking exit status after the job runs.
# crontab -e
0 2 * * * /opt/scripts/nightly-convert.sh >> /var/log/ctf-nightly.log 2>&1 || /opt/scripts/alert.sh "nightly-convert failed"
The use case
You have a drop directory — /data/incoming/ — where upstream processes deposit files throughout the day. Every night at 2am, you want everything converted to a target format and moved to /data/converted/. Files that have already been converted should be skipped (idempotent). The job should log what it did, and if anything fails, you should know before morning standup.
Common real-world versions of this pattern:
- DOCX → PDF for a legal team's nightly document archive
- PNG screenshots → WebP for a build pipeline's asset optimization step
- Raw audio recordings → MP3 for podcast episode processing
- Uploaded CSVs → JSON for a data warehouse ingestion job
The ChangeThisFile API handles 690 routes — source format is auto-detected from filename, so you only need to specify the target.
Working bash script
#!/usr/bin/env bash
set -euo pipefail
# ---- config ----------------------------------------------------------------
API_KEY="${CTF_API_KEY:?CTF_API_KEY not set}"
INPUT_DIR="${CTF_INPUT_DIR:-/data/incoming}"
OUTPUT_DIR="${CTF_OUTPUT_DIR:-/data/converted}"
TARGET_FORMAT="${CTF_TARGET_FORMAT:-pdf}"
API_URL="https://changethisfile.com/v1/convert"
DONE_LOG="${CTF_DONE_LOG:-/var/lib/ctf/converted.log}"
# ---------------------------------------------------------------------------
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*"; }
mkdir -p "$OUTPUT_DIR" "$(dirname "$DONE_LOG")"
touch "$DONE_LOG"
converted=0
skipped=0
failed=0
for f in "$INPUT_DIR"/*; do
[[ -f "$f" ]] || continue
filename=$(basename "$f")
stem="${filename%.*}"
out="$OUTPUT_DIR/${stem}.${TARGET_FORMAT}"
# Idempotency: skip files we already converted
if grep -qF "$filename" "$DONE_LOG"; then
log "SKIP $filename (already converted)"
((skipped++)) || true
continue
fi
log "CONVERT $filename -> ${stem}.${TARGET_FORMAT}"
http_status=$(curl -sf \
-w "%{http_code}" \
-o "$out" \
-H "Authorization: Bearer $API_KEY" \
-F "file=@$f" \
-F "target=$TARGET_FORMAT" \
"$API_URL"
) || { log "ERROR curl failed for $filename"; ((failed++)) || true; continue; }
if [[ "$http_status" == "200" ]]; then
echo "$filename" >> "$DONE_LOG"
log "OK $filename (HTTP $http_status, $(stat -c%s "$out") bytes out)"
((converted++)) || true
else
log "ERROR HTTP $http_status for $filename"
rm -f "$out" # remove partial output
((failed++)) || true
fi
done
log "DONE: converted=$converted skipped=$skipped failed=$failed"
# Exit non-zero if any conversions failed — cron will catch this
[[ $failed -eq 0 ]] || exit 1
Save to /opt/scripts/nightly-convert.sh and chmod +x it. Set CTF_API_KEY in the cron environment or a sourced file.
Error handling and retries
The script above fails fast on any curl error (-sf exits non-zero on HTTP errors and network failures). But for transient network issues, a retry wrapper is better than a hard fail:
convert_with_retry() {
local file="$1" out="$2" target="$3"
local attempts=3
local delay=10
for i in $(seq 1 $attempts); do
http_status=$(curl -sf \
--max-time 120 \
--retry 0 \
-w "%{http_code}" \
-o "$out" \
-H "Authorization: Bearer $API_KEY" \
-F "file=@$file" \
-F "target=$target" \
"$API_URL"
) && [[ "$http_status" == "200" ]] && return 0
log "RETRY $i/$attempts for $(basename "$file") (status: $http_status)"
sleep "$delay"
delay=$((delay * 2)) # exponential backoff
done
return 1
}
Use --max-time 120 rather than curl's default (infinite). Large files — video, dense PDFs — can take 60-90 seconds to convert server-side. Without a timeout, a hung conversion blocks the entire job indefinitely.
For the alert script, a simple curl to a Slack webhook covers most needs:
#!/usr/bin/env bash
# /opt/scripts/alert.sh
set -euo pipefail
SLACK_WEBHOOK="${CTF_SLACK_WEBHOOK:?}"
curl -sS -X POST "$SLACK_WEBHOOK" \
-H 'Content-Type: application/json' \
-d "{\"text\": \":rotating_light: $1 on $(hostname) at $(date -u +%Y-%m-%dT%H:%M:%SZ)\"}"
Scheduling with cron and logrotate
The full crontab entry:
# /etc/cron.d/nightly-convert or: crontab -e
SHELL=/bin/bash
CTF_API_KEY=ctf_sk_your_key_here
CTF_INPUT_DIR=/data/incoming
CTF_OUTPUT_DIR=/data/converted
CTF_TARGET_FORMAT=pdf
CTF_DONE_LOG=/var/lib/ctf/converted.log
CTF_SLACK_WEBHOOK=https://hooks.slack.com/services/T.../B.../...
# Run at 2am, append stdout+stderr to log, alert on failure
0 2 * * * root /opt/scripts/nightly-convert.sh >> /var/log/ctf-nightly.log 2>&1 || /opt/scripts/alert.sh "nightly-convert failed"
Log rotation via logrotate keeps disk usage bounded:
# /etc/logrotate.d/ctf-nightly
/var/log/ctf-nightly.log {
daily
rotate 30
compress
delaycompress
missingok
notifempty
create 0640 root adm
}
With daily and rotate 30, you keep 30 days of compressed logs. Each compressed day is typically a few KB for a nightly job — negligible disk cost.
Test your logrotate config without waiting: logrotate -f /etc/logrotate.d/ctf-nightly
Production tips
- Lock against concurrent runs. If conversion takes longer than 24 hours (unlikely but possible with large batches), a second cron run will start. Use
flock: wrap the script call withflock -n /var/lock/ctf-nightly.lock /opt/scripts/nightly-convert.sh. - Track the done log in a separate filesystem from the output. If the output disk fills up, you want the done log to remain intact so you don't re-convert everything on the next run.
- Use MAILTO for low-cost alerting. Set
MAILTO=ops@yourcompany.comin the crontab — cron will email you the script's output on any non-zero exit, no Slack webhook required for basic setups. - Free tier covers 1,000 conversions/month. For a nightly job processing under 33 files/day on average, that's enough. If you're near the limit, paid plans start at $29/mo for 10K conversions.
- Verify the output before marking done. Check that the output file exists and has non-zero size before appending to the done log. A successful HTTP 200 response doesn't guarantee the output wrote correctly to disk.
A nightly conversion cron job needs four things to be production-ready: structured logging, idempotency (done log), failure alerting (MAILTO or webhook), and log rotation. The pattern above handles all four in under 80 lines of bash. Get a free API key to run it today — 1,000 conversions/month at no cost.