Cu
Developer Tools AWS

Crontab UI on AWS User Guide

| Product: Crontab UI 0.4.2 on Ubuntu 24.04 on AWS

Overview

This guide covers launching and using the Crontab UI 0.4.2 on Ubuntu 24.04 AMI from cloudimg on Amazon EC2. It is one version of the cloudimg Container Platforms and PaaS listing on AWS Marketplace; you pick it as the software version when you subscribe and launch.

Crontab UI is an open source, MIT licensed web interface for managing cron jobs. Instead of editing crontab text over SSH, you create, edit, pause, resume and run jobs from a browser, see each job's schedule in plain words, read the output and error log of every run, and import, export, back up and restore the whole job list. The system cron daemon runs the jobs on schedule.

Upstream Crontab UI has no sign in by default and runs every job as the account it runs as. On this image:

  • A password unique to your instance, from the first boot. nginx guards every route with HTTP Basic Auth (user admin). The password is generated on your instance's first boot and stored only as a bcrypt hash for nginx and in a root only file. Only the static /healthz answers without it. If the password file is missing, unreadable, malformed or empty, nginx refuses to start and never serves the interface.
  • Jobs run as their own account. The web interface runs as the unprivileged account crontabui; every job, scheduled or started with Run now, runs as a second unprivileged account, cronjobs, with no shell, no password and no sudo. A job cannot read the web interface's internal credential, its process environment or memory, and it can neither read nor change its job database: the folders the two accounts share are sticky, and the interface refuses to follow a link or open a pipe that a job leaves there. The image is built and tested to prove that from inside a real job (Step 9).
  • HTTPS from the first boot. nginx serves the interface on port 443 with a certificate generated on your instance's first boot. Port 80 redirects to HTTPS. Requests for a host name the instance does not serve are dropped.
  • Cross site writes refused. Browsers send Basic Auth credentials with cross site requests, so nginx refuses any request that changes state (POST, PUT, PATCH or DELETE) when its Origin or Referer names another site.
  • Loopback only, fenced by user. Crontab UI listens on 127.0.0.1:8000, and nftables lets only nginx and root open that port. It also checks an internal credential that only nginx sends. Nothing but 22, 80 and 443 listens on the network.
  • An egress fence for jobs. The cronjobs and crontabui accounts may reach public addresses only. The instance metadata service, link local addresses, loopback, private and reserved ranges, your VPC's own address blocks and IPv6 are refused. You can allow a private server in one file (Step 8).
  • No telemetry. Crontab UI makes no outbound connection of its own; the build proves it by counting the service's outbound connections and DNS queries across a full workload: there are none.
  • Pinned, audited software. Node.js v24.22.2 LTS from the official tarball (checksum pinned) and crontab-ui 0.4.2 with exactly its integrity locked production dependency tree. Mail notifications are removed (see Step 12). Ubuntu 24.04 LTS is fully patched at build time and unattended security upgrades are on.

Crontab UI is an independent open source project by Suresh Alse. This image is produced by cloudimg and is not affiliated with, endorsed by, or sponsored by the Crontab UI project. It ships the free and open source MIT licensed software.

What is on the image: Crontab UI 0.4.2 in /opt/crontab-ui, Node.js v24.22.2 in /opt/node, nginx, cron, nftables, the licences and build provenance in /usr/share/doc/crontab-ui, Ubuntu 24.04 LTS, cloud-init and the AWS Systems Manager agent. Jobs, their logs and backups live in /var/lib/crontab-ui.

Default user: ubuntu · Architecture: x86_64 · Recommended instance type: m5.large

Prerequisites

  • An AWS account subscribed to the listing, an EC2 key pair and a VPC subnet with a route to the internet.
  • A security group that allows TCP 22 (SSH), TCP 443 (the web interface) and TCP 80 (redirects to HTTPS) from your own networks only.
  • m5.large (2 vCPU, 8 GiB) or larger. Measured on the build instance after a full workload, Crontab UI uses about 85 MiB of memory and nginx about 30 MiB, and Crontab UI answers about a second after a start. Crontab UI itself is light: size the instance for the jobs you schedule.

Step 1: Launch the instance

In the AWS Marketplace listing choose Continue to Subscribe, then Continue to Configuration, pick Crontab UI 0.4.2 on Ubuntu 24.04 as the software version and your Region, then Launch through EC2. Choose m5.large, your key pair and a security group as above.

With the AWS CLI (replace the values in angle brackets):

aws ec2 run-instances --region <region> --image-id <ami-id-from-the-listing> \
  --instance-type m5.large --key-name <your-key-pair> \
  --security-group-ids <your-security-group> --subnet-id <your-subnet> \
  --metadata-options HttpTokens=required,HttpEndpoint=enabled \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=crontab-ui}]'

First boot takes under a minute: it generates the instance's password, internal credential and certificate, proves the access controls and only then lets nginx start.

Step 2: Connect to your instance

Version SSH user
Crontab UI 0.4.2 on Ubuntu 24.04 ubuntu
ssh -i <your-key.pem> ubuntu@<public-ip>

Step 3: Check that Crontab UI answers

Check the service itself, not just the unit state. The services, then the health endpoint and the signed in page through nginx on HTTPS:

systemctl is-active crontab-ui-egress-guard crontab-ui-jobs.socket crontab-ui nginx cron
H=$(sudo sed -n 's#^CRONTAB_UI_URL=https://\([^/]*\)/.*#\1#p' /root/crontab-ui-credentials.txt)
PW=$(sudo sed -n 's/^CRONTAB_UI_PASSWORD=//p' /root/crontab-ui-credentials.txt)
cu() { curl -s --cacert /etc/nginx/ssl/crontab-ui.crt --resolve "$H:443:127.0.0.1" "$@"; }
C=$(cu -o /dev/null -w '%{http_code}' "https://$H/healthz")
T=$(cu -K <(printf 'user = "admin:%s"\n' "$PW") "https://$H/" | grep -o '<title>[^<]*</title>')
V=$(/opt/node/bin/node -p 'require("/opt/crontab-ui/node_modules/crontab-ui/package.json").version')
echo "GET /healthz -> HTTP $C"
echo "GET / with your password -> $T"
echo "Crontab UI $V on Node.js $(/opt/node/bin/node --version)"
[ "$C" = 200 ] && [ "$T" = "<title>Crontab UI</title>" ] && [ "$V" = 0.4.2 ] && echo CRONTAB_UI_OK

Expected output:

active
active
active
active
active
GET /healthz -> HTTP 200
GET / with your password -> <title>Crontab UI</title>
Crontab UI 0.4.2 on Node.js v24.22.2
CRONTAB_UI_OK

Step 4: Get your URL and password

sudo cat /root/crontab-ui-credentials.txt

The file is readable by root only. Your values differ:

# Crontab UI - this instance's credentials (cloudimg, minted at first boot). Keep this file secret.
CRONTAB_UI_URL=https://ec2-<public-ip-dashed>.compute-1.amazonaws.com/
CRONTAB_UI_USER=admin
CRONTAB_UI_PASSWORD=<24 characters generated on your instance>
CRONTAB_UI_TLS_SHA256_FINGERPRINT=<unique to your instance>
# Change the password: sudo crontab-ui-set-password

CRONTAB_UI_URL uses the instance's public DNS name from EC2 instance metadata, and the certificate also names the public and private IP addresses. If you stop and start the instance and its public address changes, the next boot updates the name, the certificate and this file. To serve your own DNS name, see Step 10.

Step 5: Sign in and create a job

Open CRONTAB_UI_URL in your browser. The certificate is self signed for your instance, so the browser warns once; compare the fingerprint it shows with CRONTAB_UI_TLS_SHA256_FINGERPRINT before you accept it. The browser then asks for a user name and password: enter admin and CRONTAB_UI_PASSWORD. You land on the job list, which is empty on a new instance. Here it holds three example jobs:

The Crontab UI job list with three example jobs, served over HTTPS by the instance

Choose New, give the job a name, enter its command and pick a schedule, either with a quick schedule button or in the minute, hour, day, month and week fields. Tick Enable error logging to keep each run's output and errors, then Save. Every save is written straight to the real crontab (autosave is on), so there is no separate step to apply it. Hooks takes a command that receives the job's output on standard input after each run; it runs as the same job account.

The New job dialog

Each job row has Run now, Edit, Disable (pause) or Enable, duplicate and delete buttons, and, when logging is on, buttons for the job's last output (stdout) and error log (stderr). Backup, Export and Import at the top save or load the whole job list, Preview crontab shows exactly what is installed, and the Environment Variables box sets variables for every job (for example PATH).

Preview crontab shows the lines as installed. Crontab UI wraps each command so that its output and errors are kept per job under /var/lib/crontab-ui:

The crontab preview: each job's schedule and its wrapped command

Commands run with /bin/sh as the cronjobs account, in its home directory /var/lib/crontab-ui/home, with the usual cron environment. Write a literal % in a command as \%: cron turns an unescaped % into a new line.

Step 6: The access model

nginx is the only way in. Every request except /healthz needs the password, a cross site write is refused even with the password, and only nginx and root may open Crontab UI's own port:

H=$(sudo sed -n 's#^CRONTAB_UI_URL=https://\([^/]*\)/.*#\1#p' /root/crontab-ui-credentials.txt)
PW=$(sudo sed -n 's/^CRONTAB_UI_PASSWORD=//p' /root/crontab-ui-credentials.txt)
cu() { curl -s --cacert /etc/nginx/ssl/crontab-ui.crt --resolve "$H:443:127.0.0.1" -o /dev/null -w '%{http_code}' "$@"; }
A=$(cu "https://$H/export")
W=$(cu -K <(printf 'user = "admin:%s"\n' "wrong-password-$RANDOM") "https://$H/export")
G=$(cu -K <(printf 'user = "admin:%s"\n' "$PW") "https://$H/export")
X=$(cu -K <(printf 'user = "admin:%s"\n' "$PW") -X POST -H 'Origin: https://evil.example' -d '_id=none' "https://$H/remove")
N=$(sudo setpriv --reuid=nobody --regid=nogroup --clear-groups curl -s -o /dev/null -m 4 -w '%{http_code}' http://127.0.0.1:8000/ || true)
R=$(sudo curl -s -o /dev/null -m 4 -w '%{http_code}' http://127.0.0.1:8000/)
echo "no password -> HTTP $A, wrong password -> HTTP $W, your password -> HTTP $G"
echo "cross site POST with your password -> HTTP $X"
echo "another user to port 8000 -> HTTP $N; root to port 8000 without the internal credential -> HTTP $R"
sudo ss -Htln | awk '{print $4}' | sort -u | tr '\n' ' '; echo
[ "$A" = 401 ] && [ "$W" = 401 ] && [ "$G" = 200 ] && [ "$X" = 403 ] && [ "$N" = 000 ] && [ "$R" = 401 ] && echo ACCESS_OK

Expected output:

no password -> HTTP 401, wrong password -> HTTP 401, your password -> HTTP 200
cross site POST with your password -> HTTP 403
another user to port 8000 -> HTTP 000; root to port 8000 without the internal credential -> HTTP 401
0.0.0.0:22 0.0.0.0:443 0.0.0.0:80 127.0.0.1:8000 127.0.0.53%lo:53 127.0.0.54:53 [::]:22 [::]:443 [::]:80
ACCESS_OK

HTTP 000 means the connection was refused. Even root, who may open the port, is refused by Crontab UI itself without the internal credential that only nginx holds. Scripts and curl send no Origin or Referer, so the cross site guard does not affect them; for the routes that change state on a GET (/crontab, /backup, /delete, /restore_backup, /import_crontab) send the header X-Requested-With: XMLHttpRequest, as the page itself does. Requests are limited to 3,000 a minute per client, with a burst of 1,000 (an IPv4 client is counted by its address and an IPv6 client by its /64 network); Crontab UI adds its own limit of 300 requests per 15 minutes per client, keyed on the real client address nginx passes. A client over a limit gets HTTP 429.

The password is checked by nginx with bcrypt, so wrong guesses are slow, and the password is 24 random letters and digits. If the password file is ever missing, unreadable, malformed or empty, nginx does not start: there is no state in which the interface is served without the password.

The image also ships a full round trip check you can run at any time. It signs in over HTTPS, saves a job, proves it reaches the cronjobs crontab and leaves it again, and checks the refusals above:

sudo crontab-ui-cred-roundtrip

Expected output:

OK https 301 444 healthz-200 anon-401 wrong-401 app-direct-401 ui-200 csrf-origin-403 csrf-xrw-403 csrf-fetchsite-403 job-saved-to-crontab job-removed

Step 7: Jobs from a script

Everything the page does is a plain HTTP call, so you can manage jobs from a script with the same password. This example saves a job, runs it once with Run now, reads its output, checks the real crontab and deletes the job again:

H=$(sudo sed -n 's#^CRONTAB_UI_URL=https://\([^/]*\)/.*#\1#p' /root/crontab-ui-credentials.txt)
PW=$(sudo sed -n 's/^CRONTAB_UI_PASSWORD=//p' /root/crontab-ui-credentials.txt)
cu() { curl -s --cacert /etc/nginx/ssl/crontab-ui.crt --resolve "$H:443:127.0.0.1" -K <(printf 'user = "admin:%s"\n' "$PW") "$@"; }
NAME="guide-example-$RANDOM"
cu -X POST -d _id=-1 --data-urlencode "name=$NAME" --data-urlencode 'command=echo "hello from $(id -un)"' \
  --data-urlencode 'schedule=0 3 * * *' -d logging=true -d mailing= "https://$H/save"
ID=$(cu "https://$H/export" | python3 -c 'import json,sys
d={}
for l in sys.stdin:
    j=json.loads(l); d[j["_id"]]=j
print(next(i for i,j in d.items() if j.get("name")=="'"$NAME"'" and not j.get("$$deleted")))')
cu -X POST -d "_id=$ID" "https://$H/runjob"
for i in $(seq 1 30); do OUT=$(cu "https://$H/stdout?id=$ID" | tail -1); [ "$OUT" = "hello from cronjobs" ] && break; sleep 1; done
for i in $(seq 1 30); do IN=$(sudo crontab -u cronjobs -l 2>/dev/null | grep -c "$ID"); [ "$IN" = 1 ] && break; sleep 1; done
echo "job $NAME output: $OUT"
echo "in the cronjobs crontab: $IN line(s)"
cu -X POST -d "_id=$ID" "https://$H/remove"
for i in $(seq 1 30); do LEFT=$(sudo crontab -u cronjobs -l 2>/dev/null | grep -c "$ID"); [ "$LEFT" = 0 ] && break; sleep 1; done
echo "after delete: $LEFT line(s)"
[ "$OUT" = "hello from cronjobs" ] && [ "$IN" = 1 ] && [ "$LEFT" = 0 ] && echo SCRIPT_OK

Expected output (the job name differs):

job guide-example-25909 output: hello from cronjobs
in the cronjobs crontab: 1 line(s)
after delete: 0 line(s)
SCRIPT_OK

The job ran as cronjobs, not as the account that serves the web interface. Saves reach the real crontab within a few seconds, which is why the example waits for each change.

Step 8: Let jobs reach a private server (optional)

Jobs may reach public addresses. The egress fence refuses every private and VPC address to the cronjobs and crontabui accounts, so a job cannot read the instance metadata service (and the instance role's credentials) or probe your network. To let jobs reach a server in your VPC, for example a database a job backs up, list its CIDR in the allow file and reload the fence:

echo '10.0.5.25/32' | sudo tee -a /etc/cloudimg/crontab-ui-egress-allow
sudo systemctl reload crontab-ui-egress-guard

Metadata, link local, loopback and multicast ranges are always refused and cannot be listed. A line that is not a CIDR, or a range that is too broad, is rejected and the fence stays as it was.

Step 9: What a job can and cannot see

The web interface account crontabui holds an internal credential and Crontab UI's job database; the job account cronjobs holds neither. From the job account's point of view (this is exactly what a job's command runs as), the credential, the web interface's process and its database are all refused, while root and the web interface still read the credential:

MAIN=$(systemctl show -p MainPID --value crontab-ui)
asjob() { sudo setpriv --reuid=cronjobs --regid=cronjobs --clear-groups "$@"; }
id cronjobs; id crontabui
for f in /run/credentials/crontab-ui.service/app-auth /proc/$MAIN/environ /var/lib/crontab-ui/crontab.db /etc/cloudimg/crontab-ui-app-auth; do
  if asjob cat "$f" >/dev/null 2>&1; then echo "cronjobs READ $f"; else echo "cronjobs refused $f"; fi
done
echo "root reads the credential: $(sudo cat /run/credentials/crontab-ui.service/app-auth | wc -c) bytes"
echo "secret names in a job's environment: $(asjob env | grep -cE 'BASIC_AUTH|CREDENTIALS_DIRECTORY')"
[ "$(asjob cat /proc/$MAIN/environ 2>/dev/null | wc -c)" = 0 ] && echo JOB_ISOLATION_OK

Expected output (the account numbers and the process id differ):

uid=995(cronjobs) gid=986(cronjobs) groups=986(cronjobs)
uid=997(crontabui) gid=987(crontabui) groups=987(crontabui)
cronjobs refused /run/credentials/crontab-ui.service/app-auth
cronjobs refused /proc/31166/environ
cronjobs refused /var/lib/crontab-ui/crontab.db
cronjobs refused /etc/cloudimg/crontab-ui-app-auth
root reads the credential: 41 bytes
secret names in a job's environment: 0
JOB_ISOLATION_OK

The build goes further than this: it starts real jobs through the web interface, one with Run now and one on a cron schedule, that try each of these reads and dump their own environment, and it fails unless every attempt is refused and no byte of any secret appears in their output.

Step 10: Your own DNS name and certificate

Point a DNS record at the instance, then set the name Crontab UI is served as. This mints a new self signed certificate for the name, updates nginx, the cross site guard and the URL and fingerprint in /root/crontab-ui-credentials.txt, and keeps the name across reboots:

sudo crontab-ui-set-hostname <your-domain>

To use a certificate from your own or a public certificate authority instead, install it over the generated pair and reload nginx (running crontab-ui-set-hostname again replaces it with a self signed one):

sudo install -m 0644 <your-domain>.crt /etc/nginx/ssl/crontab-ui.crt
sudo install -m 0600 <your-domain>.key /etc/nginx/ssl/crontab-ui.key
sudo nginx -t && sudo systemctl reload nginx

Step 11: Change the password

sudo crontab-ui-set-password

This generates a new 24 character password, replaces the bcrypt entry nginx checks, updates /root/crontab-ui-credentials.txt and reloads nginx. It does not print the password; read it from the file. To choose your own (24 to 128 letters and digits), pass it on standard input so it never appears on a command line:

sudo crontab-ui-set-password --stdin < <a-file-holding-your-password>

Step 12: Security checks

The egress fence in force, with each rule's purpose:

sudo nft list table inet cloudimg_crontabui_egress | grep -oE 'comment "cloudimg-[a-z0-9]+"' | sort -u

Expected output:

comment "cloudimg-allow"
comment "cloudimg-allow6"
comment "cloudimg-fence"
comment "cloudimg-ipv6"
comment "cloudimg-local8000"
comment "cloudimg-loopback"
comment "cloudimg-metadata"
comment "cloudimg-metadata6"
comment "cloudimg-multicast"
comment "cloudimg-private"
comment "cloudimg-reply"
comment "cloudimg-vpc"

The metadata service and your VPC are refused to jobs, while root on the same instance reaches them (the control). Your VPC's DNS resolver (TCP port 53 at the VPC base address plus two) stands in for any service in your VPC:

I=$(sudo curl -s -m 4 -o /dev/null -w '%{http_code}' -X PUT -H 'X-aws-ec2-metadata-token-ttl-seconds: 60' http://169.254.169.254/latest/api/token)
N=$(sudo setpriv --reuid=cronjobs --regid=cronjobs --clear-groups curl -s -m 4 -o /dev/null -w '%{http_code}' -X PUT -H 'X-aws-ec2-metadata-token-ttl-seconds: 60' http://169.254.169.254/latest/api/token || true)
R=$(awk '/^nameserver/ {print $2; exit}' /run/systemd/resolve/resolv.conf)
VR=$(sudo timeout 4 bash -c "exec 3<>/dev/tcp/$R/53" 2>/dev/null && echo OPEN || echo REFUSED)
VN=$(sudo setpriv --reuid=cronjobs --regid=cronjobs --clear-groups timeout 4 bash -c "exec 3<>/dev/tcp/$R/53" 2>/dev/null && echo OPEN || echo REFUSED)
echo "metadata service: root HTTP $I, cronjobs HTTP $N"
echo "VPC resolver $R port 53: root $VR, cronjobs $VN"
[ "$I" = 200 ] && [ "$N" = 000 ] && [ "$VR" = OPEN ] && [ "$VN" = REFUSED ] && echo EGRESS_FENCE_OK

Expected output (the resolver address depends on your VPC):

metadata service: root HTTP 200, cronjobs HTTP 000
VPC resolver 172.31.0.2 port 53: root OPEN, cronjobs REFUSED
EGRESS_FENCE_OK

Name lookups from jobs still work: they go to the instance's local resolver (systemd-resolved), which forwards them to the VPC resolver itself.

Crontab UI itself fetches no URL you give it; the only programs that reach the network are your jobs, which is what the fence is for.

The services run as unprivileged accounts in systemd sandboxes that are blocked from the metadata service. The password and the TLS key are readable by root only, and the password file nginx checks holds only a bcrypt hash:

systemctl show crontab-ui -p User -p NoNewPrivileges -p ProtectSystem -p IPAddressDeny
systemctl show crontab-ui-jobs@probe -p User -p IPAddressDeny
sudo stat -c '%U:%G %a %n' /root/crontab-ui-credentials.txt /etc/nginx/.crontab-ui.htpasswd /etc/nginx/ssl/crontab-ui.key /var/lib/crontab-ui /run/crontab-ui-jobs.sock
sudo cut -c1-11 /etc/nginx/.crontab-ui.htpasswd

Expected output (systemd may list the ranges in another order):

IPAddressDeny=198.18.0.0/15 fd00:ec2::254/128 240.0.0.0/4 192.0.0.0/24 224.0.0.0/4 168.63.129.16/32 169.254.169.254/32
User=crontabui
ProtectSystem=full
NoNewPrivileges=yes
IPAddressDeny=fd00:ec2::254/128 168.63.129.16/32 198.18.0.0/15 240.0.0.0/4 169.254.169.254/32 192.0.0.0/24 224.0.0.0/4
User=cronjobs
root:root 600 /root/crontab-ui-credentials.txt
root:www-data 640 /etc/nginx/.crontab-ui.htpasswd
root:root 600 /etc/nginx/ssl/crontab-ui.key
crontabui:cronjobs 710 /var/lib/crontab-ui
crontabui:crontabui 600 /run/crontab-ui-jobs.sock
admin:$2y$1

Step 13: What is in the image, and under what licence

ls /usr/share/doc/crontab-ui
cat /usr/share/doc/crontab-ui/BUILD-PROVENANCE.txt
tail -1 /usr/share/doc/crontab-ui/dependency-licences.txt
find /opt/crontab-ui /opt/node -type f \( -iname '*.jar' -o -iname '*.war' -o -iname '*.ear' \) | wc -l

Expected output:

BUILD-PROVENANCE.txt
LICENSE.md
LICENSE.nodejs
app-manifest.sha256
dependency-licences.txt
node-manifest.sha256
npm-audit-summary.txt
npm-audit.json
ui-bundle.sha256
ui-bundle.txt
crontab-ui 0.4.2 (cloudimg build)
package: crontab-ui@0.4.2 from registry.npmjs.org, integrity sha512-b0qac4esyrf31cxYTVPB2/HGQBdaa4hAtn4+392LntSNZxZMa3rR6WzG3AfGsSx0O4QqloBpJJj2VkP1AvabWw== (tag commit e1ae9d00231d78b2755dcf0182b7f7062726e8b7, MIT)
dependencies: /opt/crontab-ui/package-lock.json (sha256 ff5704c726de2f592337528b46ca42e4845bf77cbcdbe5a4d57357461b530bc9; 119 packages, sha512 integrity each, resolved with
    npm --before 2026-11-09), installed with npm ci --omit=dev --ignore-scripts (no install script ran, no compiler)
removed after install: bin/crontab-ui-mailer.js and node_modules/nodemailer (mail notifications are unavailable: the
    mailer path Crontab UI hard-codes, /usr/local/bin/node, does not exist on this image)
runtime: Node.js v24.22.2 (https://nodejs.org/dist/v24.22.2/node-v24.22.2-linux-x64.tar.xz, sha256 48cc0e21e048790e54c0cf5749c2725005313d652651c40b7a21feb9697aaf75); npm, npx and corepack removed
LICENCE-SCAN OK (118 packages)
0

Crontab UI is licensed under the MIT License; its full text ships in /usr/share/doc/crontab-ui/LICENSE.md, and Node.js's licence in LICENSE.nodejs. dependency-licences.txt lists the licence of each of the 118 installed packages: all are permissive open source licences (MIT, ISC, BSD and Apache 2.0). The web interface's own files (jQuery, Bootstrap, Bootstrap Icons and DataTables, all MIT) are listed with their checksums in ui-bundle.txt and ui-bundle.sha256. npm-audit.json is the dependency audit at build time. There are no Java archives on the image.

Mail notifications are not available on this image. Crontab UI's mailer depends on nodemailer 8, which has open high severity advisories, so the mailer and nodemailer were removed. The mail options in the job dialog do nothing; jobs still run and log normally. To be told about failures, have the job itself send a notification (for example with curl to a webhook) or check its error log.

Day to day operation

systemctl status crontab-ui --no-pager | head -3
sudo crontab -u cronjobs -l | tail -3
sudo ls /var/lib/crontab-ui
  • Restart Crontab UI with sudo systemctl restart crontab-ui and reload nginx with sudo systemctl reload nginx. Restarting Crontab UI does not stop scheduled jobs: the system cron daemon runs them.
  • Run now limits. Up to 24 jobs started with Run now can run at once, and each is stopped after 6 hours. Scheduled runs by the cron daemon have neither limit.
  • The cron daemon waits for the egress fence. cron starts only while the fence is loaded, so scheduled jobs never run without it. If the fence is missing, cron retries every 30 seconds and starts by itself once it is back.
  • Data. The job database (crontab.db, env.db), backups and job logs live in /var/lib/crontab-ui; each job's last output is in /var/lib/crontab-ui/spool and its error log in /var/lib/crontab-ui/logs. The rendered crontab is cronjobs's, in /var/spool/cron/crontabs/cronjobs.
  • Back up with Backup or Export in the interface, or an EBS snapshot of the root volume.
  • Upgrades. Unattended upgrades keep Ubuntu and nginx patched. Crontab UI and Node.js are pinned; to move to newer releases, back up first and read the upstream release notes.

Troubleshooting

  • Nothing answers on port 443. nginx starts only after first boot has completed and only with a valid password file: sudo systemctl status crontab-ui-firstboot nginx --no-pager and sudo journalctl -u crontab-ui-firstboot --no-pager | tail -30. If first boot fails it leaves nothing listening on purpose.
  • The browser keeps asking for the password. Use the user admin and the password from /root/crontab-ui-credentials.txt; after crontab-ui-set-password, close the browser tab so it forgets the old one.
  • The browser warns about the certificate. Expected until you install your own certificate; compare the fingerprint with CRONTAB_UI_TLS_SHA256_FINGERPRINT.
  • HTTP 403 when saving from a page or tool. The request carried an Origin, Referer or Sec-Fetch-Site for another site, or a state changing GET came without X-Requested-With: XMLHttpRequest. Open the interface at the exact CRONTAB_UI_URL host name, or after changing the name run crontab-ui-set-hostname so the guard follows it.
  • HTTP 444 or an empty reply. The request used a host name the instance does not serve; use the name in CRONTAB_UI_URL.
  • A job cannot reach a server in my VPC. Allow it in /etc/cloudimg/crontab-ui-egress-allow (Step 8).
  • A job needs root or another account's files. Jobs run as the unprivileged cronjobs account by design. Give cronjobs access to exactly what the job needs (for example, add it to a group that owns the files), rather than running jobs as root.
  • A red banner says the last crontab install failed. The jobs on the page were not written to the real crontab. Read the reason in the banner and in sudo journalctl -u crontab-ui --no-pager | tail -20, fix it, then choose Save to crontab. The banner clears after the next successful install.
  • Scheduled jobs stopped running. Check systemctl status cron --no-pager. If it reports that the egress backstop is not loaded, run sudo systemctl restart crontab-ui-egress-guard; cron starts again within 30 seconds.
  • A command with % in it does something odd. Escape each % as \% (cron's own rule).

Security recommendations

  • Allow ports 22, 80 and 443 in the security group from your own networks only.
  • Change the password after first sign in (Step 11) and keep /root/crontab-ui-credentials.txt readable by root only.
  • Use your own DNS name and a certificate your clients trust (Step 10).
  • List only the private servers your jobs need in the egress allow file.
  • Keep the operating system patched; unattended security upgrades are on.

Support and licensing

Crontab UI is open source under the MIT licence; there is no per user licence fee. The AMI price covers cloudimg's hardening, first boot setup and support.

  • Email: support@cloudimg.co.uk
  • Website: https://www.cloudimg.co.uk
  • Support: 24/7, with a 24 hour response SLA