diff --git a/CHANGELOG.md b/CHANGELOG.md
index c9e5316ca023815ed9172b155622f885a8bed915..53e6808fa131e78799f80f875e66b8fc4a002f0f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
-## Unreleased
-
+## [0.3.0] - 2021-09-28
+
+* Cron overhaul:
+  - Change the sidecar container to run a cron daemon instead of the backup
+    script with manual sleep.
+  - Put the backup script in a crontab.
+  - Remove the kubernetes CronJob that did the wordpress cron calling,
+    replacing it by a regular cronjob.
+  - Allow custom crontab entries, provided from a helm value.
 * Update mariadb chart to 9.6.0
   NOTE: the mariadb chart does not provide backwards compatibility in this
   case, so manual action is required if you want to upgrade an existing
diff --git a/Chart.yaml b/Chart.yaml
index 418aaff423fcee679febde8ab062f21dba5e42b6..662d7f153c8ba6e961471940552d31c59dd8267e 100644
--- a/Chart.yaml
+++ b/Chart.yaml
@@ -5,7 +5,7 @@ description: WordPress with a replicated MariaDB backend
 name: wordpress
 # Please only change the chart version as part of the release procedure: see
 # RELEASING.md
-version: 0.2.2
+version: 0.3.0
 icon: https://make.wordpress.org/design/files/2016/09/WordPress-logotype-wmark.png
 dependencies:
   - name: mariadb
diff --git a/README.md b/README.md
index 5243d15476af4de8e2391b82eac1020ca0f7301a..e4324e519e77b746b7404a14ba5e9bdf58c2c3b8 100644
--- a/README.md
+++ b/README.md
@@ -91,6 +91,9 @@ $ kubectl logs <pod> -c init-wordpress
 Helm will set up the kubernetes pods that are needed to run your website:
 
 1. A WordPress pod that serves the site
+  - If you have `backup.enabled` or `wordpress.mu_cron.enabled`, or have a
+    non-empty `customCron`, this pod will also contain a sidecar container that
+    runs cron jobs.
 2. Two MariaDB pods running the database (master-slave setup by default, unless
    you changed this in `values-local.yaml`)
 3. If you configured Redis, a Redis pod is also set up
diff --git a/templates/cron-schedule.yaml b/templates/cron-schedule.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..a9ab91aa4cf405813ebd94d99ae8937578714071
--- /dev/null
+++ b/templates/cron-schedule.yaml
@@ -0,0 +1,20 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: {{ include "wordpress.fullname" . }}-cron-schedule
+  labels:
+    app: {{ include "wordpress.name" . }}
+    chart: {{ include "wordpress.chart" . }}
+    release: {{ .Release.Name }}
+    heritage: {{ .Release.Service }}
+data:
+  www-data: |-
+{{- if .Values.wordpress.mu_cron.enabled }}
+    {{ .Values.wordpress.mu_cron.cronjob.schedule }}  curl -s -w '%{http_code}' {{- if .Values.wordpress.mu_cron.cronjob.curlInsecure }} -k {{- end }} -L 'http://{{ include "wordpress.fullname" . }}:{{ .Values.service.port }}{{ .Values.wordpress.mu_cron.cronjob.path }}?doing_wp_cron&{{ required "Please set wordpress.mu_cron.secret to a random secret" .Values.wordpress.mu_cron.secret }}'
+{{- end }}
+{{- if .Values.backup.enabled }}
+    {{ .Values.backup.schedule }}  cd /var/local/ansible && ansible-playbook backup.yml -e @secrets/secret-vars.yaml
+{{- end }}
+{{- range .Values.customCron }}
+    {{ .schedule }}  {{ .command }}
+{{- end }}
diff --git a/templates/cronjob.yaml b/templates/cronjob.yaml
deleted file mode 100644
index d344d56bcdc1e666dcfee8466b35299eb75c895a..0000000000000000000000000000000000000000
--- a/templates/cronjob.yaml
+++ /dev/null
@@ -1,94 +0,0 @@
-{{- if .Values.wordpress.mu_cron.enabled }}
-apiVersion: batch/v1beta1
-kind: CronJob
-metadata:
-  name: {{ template "wordpress.fullname" . }}
-  labels:
-    app: {{ include "wordpress.name" . }}
-    chart: {{ include "wordpress.chart" . }}
-    release: {{ .Release.Name }}
-    heritage: {{ .Release.Service }}
-  annotations:
-    {{- toYaml .Values.wordpress.mu_cron.cronjob.annotations | nindent 4 }}
-spec:
-  schedule: "{{ .Values.wordpress.mu_cron.cronjob.schedule }}"
-  concurrencyPolicy: Forbid
-  {{- with .Values.wordpress.mu_cron.cronjob.failedJobsHistoryLimit }}
-  failedJobsHistoryLimit: {{ . }}
-  {{- end }}
-  {{- with .Values.wordpress.mu_cron.cronjob.successfulJobsHistoryLimit }}
-  successfulJobsHistoryLimit: {{ . }}
-  {{- end }}
-  jobTemplate:
-    metadata:
-      labels:
-        app.kubernetes.io/name: {{ include "wordpress.name" . }}
-        app.kubernetes.io/managed-by: {{ .Release.Service }}
-    spec:
-      {{- with .Values.wordpress.mu_cron.cronjob.backoffLimit }}
-      backoffLimit: {{ . }}
-      {{- end }}
-      template:
-        metadata:
-          labels:
-            app.kubernetes.io/name: {{ include "wordpress.name" . }}
-            app.kubernetes.io/managed-by: {{ .Release.Service }}
-        spec:
-          # Set a custom service account which has access to the WordPress
-          # statefulset's state
-          serviceAccountName: {{ include "wordpress.fullname" . }}-cron
-          restartPolicy: Never
-          {{- if (default .Values.image.pullSecrets .Values.wordpress.mu_cron.cronjob.image.pullSecrets) }}
-          imagePullSecrets:
-          {{- range (default .Values.image.pullSecrets .Values.wordpress.mu_cron.cronjob.image.pullSecrets) }}
-            - name: {{ . }}
-          {{- end }}
-          {{- end }}
-          containers:
-            - name: {{ .Chart.Name }}-cron-caller
-              image: "{{ default .Values.image.repository .Values.wordpress.mu_cron.cronjob.image.repository }}:{{ default .Values.image.tag .Values.wordpress.mu_cron.cronjob.image.tag }}"
-              imagePullPolicy: {{ default .Values.image.pullPolicy .Values.wordpress.mu_cron.cronjob.image.pullPolicy }}
-              command: [ "/bin/bash" ]
-              args:
-                - -c
-                - |
-                  # NOTE: we use "{{` ... `}}" to make sure the curly braces are not templated by Helm. Returns <#readyReplicas>,<#replicasWanted>
-                  equation=$(kubectl get statefulset {{ include "wordpress.fullname" . }} --template '{{ `{{.status.readyReplicas}},{{.status.replicas}}` }}')
-                  # Make sure kubectl command did not fail
-                  if [ $? -ne 0 ]; then
-                      echo "Kubernetes command failed";
-                      exit 2;
-                  fi
-                  # Check if part before comma and after comma are equal
-                  if [[ "${equation%,*}" == "${equation#*,}" ]]; then
-                    output=$(curl -s -w '%{http_code}' {{- if .Values.wordpress.mu_cron.cronjob.curlInsecure }} -k {{- end }} -L 'http://{{ include "wordpress.fullname" . }}:{{ .Values.service.port }}{{ .Values.wordpress.mu_cron.cronjob.path }}?doing_wp_cron&{{ required "Please set wordpress.mu_cron.secret to a random secret" .Values.wordpress.mu_cron.secret }}')
-                    # Note that if the output is 200invalid secret string, you
-                    # need to provide the correct secret!
-                    if [[ "$output" == "200" ]]; then
-                      echo "success";
-                      exit 0
-                    else
-                      echo "failed with output '$output'";
-                      exit 1
-                    fi
-                  fi
-                  # If we reach this point, the statefulset is not ready yet
-                  echo "Service is not ready, doing nothing"
-                  exit 0
-              {{- with .Values.wordpress.mu_cron.cronjob.resources }}
-              resources:
-                {{ toYaml . | nindent 16 }}
-              {{- end }}
-    {{- with (default .Values.nodeSelector .Values.wordpress.mu_cron.cronjob.nodeSelector) }}
-          nodeSelector:
-{{ toYaml . | indent 12 }}
-    {{- end }}
-    {{- with (default .Values.affinity .Values.wordpress.mu_cron.cronjob.affinity) }}
-          affinity:
-{{ toYaml . | indent 12 }}
-    {{- end }}
-    {{- with (default .Values.tolerations .Values.wordpress.mu_cron.cronjob.tolerations) }}
-          tolerations:
-{{ toYaml . | indent 12 }}:
-    {{- end }}
-{{- end }}
diff --git a/templates/rbac.yaml b/templates/rbac.yaml
deleted file mode 100644
index 78467f25f3900348ba9dc14b8bc73bdbd5b7c92c..0000000000000000000000000000000000000000
--- a/templates/rbac.yaml
+++ /dev/null
@@ -1,29 +0,0 @@
-{{- if .Values.wordpress.mu_cron.enabled }}
-apiVersion: rbac.authorization.k8s.io/v1
-kind: Role
-metadata:
-  name: get-{{ include "wordpress.fullname" . }}-statefulset
-rules:
-  - apiGroups: ["apps"]
-    resources: ["statefulsets"]
-    resourceNames: [{{ include "wordpress.fullname" . }}]
-    verbs: ["get"]
----
-apiVersion: rbac.authorization.k8s.io/v1
-kind: RoleBinding
-metadata:
-  name: read-{{ include "wordpress.fullname" . }}-statefulset
-subjects:
-  - kind: ServiceAccount
-    name: {{ include "wordpress.fullname" . }}-cron
-    namespace: {{ .Release.Namespace }}
-roleRef:
-  kind: Role
-  name: get-{{ include "wordpress.fullname" . }}-statefulset
-  apiGroup: rbac.authorization.k8s.io
----
-apiVersion: v1
-kind: ServiceAccount
-metadata:
-  name: {{ include "wordpress.fullname" . }}-cron
-{{- end }}
diff --git a/templates/statefulset.yaml b/templates/statefulset.yaml
index 8d5595585f5a1441fcd72d554d2f71a55b3268cc..02207ec25d2a55a81141b2cedd7663852cc83740 100644
--- a/templates/statefulset.yaml
+++ b/templates/statefulset.yaml
@@ -104,16 +104,22 @@ spec:
               subPath: .htaccess
           resources:
 {{ toYaml .Values.resources | indent 12 }}
-        {{- if .Values.backup.enabled }}
-        - name: {{ .Chart.Name }}-backup
+        {{- if or .Values.backup.enabled .Values.wordpress.mu_cron.enabled .Values.customCron }}
+        - name: {{ .Chart.Name }}-cron
           image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
           imagePullPolicy: {{ .Values.initImage.pullPolicy }}
           command:
-            - "/var/local/ansible/scripts/backup.sh"
-          env:
-            - name: BACKUP_INTERVAL_SECONDS
-              # A day's worth of seconds.
-              value: {{ .Values.backup.intervalSeconds | quote }}
+            # Busybox's cron daemon.
+            - "crond"
+            # Run in foreground.
+            - "-f"
+            # Log to stderr, with level 6.
+            - "-d"
+            - "6"
+          # `crond` must be run as root, so we override the pod's security context.
+          securityContext:
+            runAsNonRoot: false
+            runAsUser: 0
           volumeMounts:
             - name: {{ include "wordpress.name" . }}-wp-storage
               mountPath: /var/www/html
@@ -126,6 +132,8 @@ spec:
               subPath: main.yml
             - name: ansible-secrets
               mountPath: /var/local/ansible/secrets
+            - name: cron-schedule
+              mountPath: /etc/crontabs/
             {{- if .Values.backup.sshPrivateKey }}
             - name: ssh-private-key
               mountPath: /var/local/ssh-private-key
@@ -186,3 +194,10 @@ spec:
         - name: htuploads
           configMap:
             name: {{ include "wordpress.fullname" . }}-htuploads
+        - name: cron-schedule
+          configMap:
+            name: {{ include "wordpress.fullname" . }}-cron-schedule
+            items:
+            - key: www-data
+              # This is the name of the user with id 33 in the cli container.
+              path: "xfs"
diff --git a/values-local.yaml.example b/values-local.yaml.example
index 4c78587c59daeae5dd5885f14b6cb225f7a303c9..d801919213211301a1697e1e49609fbb62fff450 100644
--- a/values-local.yaml.example
+++ b/values-local.yaml.example
@@ -175,8 +175,14 @@ redis:
 #   # If isDate is set to false then backup names are a 2 week cycle of A(day number) or B(day number)
 #   # A monthly database backup and monthly wordpress manifest are always made with monthnumber prefix 
 #   isDate: true
-#   # The interval at which backups occur. Defaults to 86400 seconds (24 hours)
-#   intervalSeconds:
+#   # The cron schedule that determines when backups are made.
+#   # Run at 3:37 every day.
+#   schedule: "37 3 * * *"
+
+# customCron:
+# - schedule: "5 * * * *"
+#   command: "echo test"
+
 # It's advisable to set resource limits to prevent your K8s cluster from
 # crashing
 # resources:
diff --git a/values.yaml b/values.yaml
index b826c68b7775384d9ecacd96329b66faa749c996..93ae529df36b3ffbe6e27b3cbe42927540d3e5ae 100644
--- a/values.yaml
+++ b/values.yaml
@@ -169,32 +169,11 @@ wordpress:
     slug: wp-cron-control
     version: cecdec276f086aafb6765ea77ce8d2ce0948e01c
     phpfile: wp-cron-control.php
-    # Optional annotations to add to the cronjob object
     cronjob:
-      image:
-        repository: bitnami/kubectl
-        tag: 1.18
-        pullPolicy: IfNotPresent
       # Every 3 minutes
       schedule: "*/3 * * * *"
       # We use the internal DNS, so there is no TLS certificate
       curlInsecure: true
-      # resources:
-        # We usually recommend not to specify default resources and to leave this as a conscious
-        # choice for the user. This also increases chances charts run on environments with little
-        # resources, such as Minikube. If you do want to specify resources, uncomment the following
-        # lines, adjust them as necessary, and remove the curly braces after 'resources:'.
-        # limits:
-        #  cpu: 100m
-        #  memory: 128Mi
-        # requests:
-        #  cpu: 100m
-        #  memory: 128Mi
-      annotations: {}
-      failedJobsHistoryLimit: 3
-      successfulJobsHistoryLimit: 1
-      # Maximum number of times a failing Job is retried.
-      backoffLimit: 1
       # Path to the cronjob PHP file (gets appended to the wordpress URL)
       path: /wp-cron.php
       # You can override this key for the cronjobs. If you don't change the
@@ -241,13 +220,13 @@ ansibleVars:
 
 image:
   repository: open.greenhost.net:4567/openappstack/wordpress-helm/wordpress
-  tag: 0.2.2
+  tag: 0.3.0
   pullPolicy: Always
   pullSecrets: []
 
 initImage:
   repository: open.greenhost.net:4567/openappstack/wordpress-helm/wordpress-cli-ansible
-  tag: 0.2.2
+  tag: 0.3.0
   pullPolicy: Always
 
 ingress:
@@ -337,9 +316,12 @@ redis:
 
 backup:
   enabled: false
-  intervalSeconds: 86400
+  # Daily at 2:00.
+  schedule: "0 2 * * *"
   isDate: true
 
+customCron: []
+
 wpSalts: {}
 
 # Some of the variables configured above are put into a variable here, that's
diff --git a/wp-cli-docker/scripts/backup.sh b/wp-cli-docker/scripts/backup.sh
deleted file mode 100755
index 341c3218ba311ba264ab4ecbbfc4ab785959b6b6..0000000000000000000000000000000000000000
--- a/wp-cli-docker/scripts/backup.sh
+++ /dev/null
@@ -1,17 +0,0 @@
-#!/bin/bash
-
-backupCommand="ansible-playbook backup.yml -e @secrets/secret-vars.yaml"
-
-while true
-do
-    date
-    echo "Waiting for $BACKUP_INTERVAL_SECONDS seconds before starting next backup."
-    sleep $BACKUP_INTERVAL_SECONDS
-    $backupCommand
-    exitCode=$?
-    if [ $exitCode -ne 0 ]
-    then
-        echo "Backup failed, exiting!"
-        exit $exitCode
-    fi
-done