diff --git a/.github/workflows/postgres.yml b/.github/workflows/postgres.yml
index 0b9596d..4ceb7a7 100644
--- a/.github/workflows/postgres.yml
+++ b/.github/workflows/postgres.yml
@@ -1,6 +1,8 @@
 name: Postgres image
 on:
   push:
+    paths:
+      - postgres/**
     branches:
       - main
     tags:
@@ -39,6 +41,8 @@ jobs:
           build-args: |
             POSTGRES_MAJOR=${{ matrix.postgres_major }}
             POSTGRES_MINOR=${{ fromJSON(env.MINOR_VERSIONS)[matrix.postgres_major] }}
+          cache-from: type=registry,ref=code.icb4dc0.de/prskr/supabase-operator/postgres:buildcache
+          cache-to: type=registry,ref=code.icb4dc0.de/prskr/supabase-operator/postgres:buildcache,mode=max
 
   manifest:
     strategy:
diff --git a/api/v1alpha1/core_types.go b/api/v1alpha1/core_types.go
index cd20586..bbd6148 100644
--- a/api/v1alpha1/core_types.go
+++ b/api/v1alpha1/core_types.go
@@ -17,6 +17,7 @@ limitations under the License.
 package v1alpha1
 
 import (
+	"bytes"
 	"context"
 	"errors"
 	"fmt"
@@ -25,6 +26,7 @@ import (
 	"slices"
 	"strconv"
 	"strings"
+	"time"
 
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -387,20 +389,75 @@ type CoreSpec struct {
 	Auth      *AuthSpec     `json:"auth,omitempty"`
 }
 
-type MigrationStatus map[string]metav1.Time
+type MigrationConditionStatus string
 
-func (s MigrationStatus) IsApplied(name string) bool {
-	_, ok := s[name]
-	return ok
-}
+const (
+	MigrationConditionStatusApplied MigrationConditionStatus = "Applied"
+	MigrationConditionStatusFailed  MigrationConditionStatus = "Failed"
+)
 
-func (s MigrationStatus) Record(name string) {
-	s[name] = metav1.Now()
+type MigrationScriptCondition struct {
+	// Name - file name of the migration script
+	Name string `json:"name"`
+	// Hash - SHA256 hash of the script when it was last successfully applied
+	Hash []byte `json:"hash"`
+	// Status - whether the migration was applied or not
+	// +kubebuilder:validation:Enum=Applied;Failed
+	Status MigrationConditionStatus `json:"status"`
+	// LastProbeTime - last time the operator tried to execute the migration script
+	LastProbeTime metav1.Time `json:"lastProbeTime,omitempty"`
+	// LastTransitionTime - last time the condition transitioned from one status to another
+	LastTransitionTime metav1.Time `json:"lastTransitionTime,omitempty"`
+	// Reason - one-word, CamcelCase reason for the condition's last transition
+	Reason string `json:"reason,omitempty"`
+	// Message - human-readable message indicating details about the last transition
+	Message string `json:"message,omitempty"`
 }
 
 type DatabaseStatus struct {
-	AppliedMigrations MigrationStatus   `json:"appliedMigrations,omitempty"`
-	Roles             map[string][]byte `json:"roles,omitempty"`
+	MigrationConditions []MigrationScriptCondition `json:"migrationConditions,omitempty" patchStrategy:"merge" patchMergeKey:"name"`
+	Roles               map[string][]byte          `json:"roles,omitempty"`
+}
+
+func (s DatabaseStatus) IsMigrationUpToDate(name string, hash []byte) (found bool, upToDate bool) {
+	for _, cond := range s.MigrationConditions {
+		if cond.Name == name {
+			return true, bytes.Equal(cond.Hash, hash)
+		}
+	}
+
+	return false, false
+}
+
+func (s DatabaseStatus) RecordMigrationCondition(name string, hash []byte, err error) {
+	var (
+		now                = time.Now()
+		newStatus          = MigrationConditionStatusApplied
+		lastProbeTime      = metav1.NewTime(now)
+		lastTransitionTime metav1.Time
+		message            string
+	)
+
+	if err != nil {
+		newStatus = MigrationConditionStatusFailed
+		message = err.Error()
+	}
+
+	for idx, cond := range s.MigrationConditions {
+		if cond.Name == name {
+			lastTransitionTime = cond.LastTransitionTime
+			if cond.Status != newStatus {
+				lastTransitionTime = metav1.NewTime(now)
+			}
+
+			cond.Hash = hash
+			cond.Status = newStatus
+			cond.LastProbeTime = lastProbeTime
+			cond.LastTransitionTime = lastTransitionTime
+			cond.Reason = "Outdated"
+			cond.Message = message
+		}
+	}
 }
 
 type CoreConditionType string
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index f3d6a98..e1ee304 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -752,11 +752,11 @@ func (in *DatabaseRolesSecrets) DeepCopy() *DatabaseRolesSecrets {
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *DatabaseStatus) DeepCopyInto(out *DatabaseStatus) {
 	*out = *in
-	if in.AppliedMigrations != nil {
-		in, out := &in.AppliedMigrations, &out.AppliedMigrations
-		*out = make(MigrationStatus, len(*in))
-		for key, val := range *in {
-			(*out)[key] = *val.DeepCopy()
+	if in.MigrationConditions != nil {
+		in, out := &in.MigrationConditions, &out.MigrationConditions
+		*out = make([]MigrationScriptCondition, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
 		}
 	}
 	if in.Roles != nil {
@@ -1046,24 +1046,25 @@ func (in *JwtSpec) DeepCopy() *JwtSpec {
 }
 
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
-func (in MigrationStatus) DeepCopyInto(out *MigrationStatus) {
-	{
-		in := &in
-		*out = make(MigrationStatus, len(*in))
-		for key, val := range *in {
-			(*out)[key] = *val.DeepCopy()
-		}
+func (in *MigrationScriptCondition) DeepCopyInto(out *MigrationScriptCondition) {
+	*out = *in
+	if in.Hash != nil {
+		in, out := &in.Hash, &out.Hash
+		*out = make([]byte, len(*in))
+		copy(*out, *in)
 	}
+	in.LastProbeTime.DeepCopyInto(&out.LastProbeTime)
+	in.LastTransitionTime.DeepCopyInto(&out.LastTransitionTime)
 }
 
-// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MigrationStatus.
-func (in MigrationStatus) DeepCopy() MigrationStatus {
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MigrationScriptCondition.
+func (in *MigrationScriptCondition) DeepCopy() *MigrationScriptCondition {
 	if in == nil {
 		return nil
 	}
-	out := new(MigrationStatus)
+	out := new(MigrationScriptCondition)
 	in.DeepCopyInto(out)
-	return *out
+	return out
 }
 
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
diff --git a/assets/migrations/migrations.go b/assets/migrations/migrations.go
index d6f97bb..84ba16a 100644
--- a/assets/migrations/migrations.go
+++ b/assets/migrations/migrations.go
@@ -17,6 +17,7 @@ limitations under the License.
 package migrations
 
 import (
+	"crypto/sha256"
 	"embed"
 	"fmt"
 	"io/fs"
@@ -32,6 +33,7 @@ var migrationsFS embed.FS
 type Script struct {
 	FileName string
 	Content  string
+	Hash     []byte
 }
 
 func InitScripts() iter.Seq2[Script, error] {
@@ -49,10 +51,18 @@ func RoleCreationScript(roleName string) (Script, error) {
 		return Script{}, err
 	}
 
-	return Script{fileName, string(content)}, nil
+	hash := sha256.New()
+	_, _ = hash.Write(content)
+
+	return Script{
+		FileName: fileName,
+		Content:  string(content),
+		Hash:     hash.Sum(nil),
+	}, nil
 }
 
 func readScripts(dir string) iter.Seq2[Script, error] {
+	hash := sha256.New()
 	return func(yield func(Script, error) bool) {
 		files, err := migrationsFS.ReadDir(dir)
 		if err != nil {
@@ -76,11 +86,16 @@ func readScripts(dir string) iter.Seq2[Script, error] {
 				}
 			}
 
+			_, _ = hash.Write(content)
+
 			s := Script{
 				FileName: file.Name(),
 				Content:  string(content),
+				Hash:     hash.Sum(nil),
 			}
 
+			hash.Reset()
+
 			if !yield(s, nil) {
 				return
 			}
diff --git a/config/crd/bases/supabase.k8s.icb4dc0.de_cores.yaml b/config/crd/bases/supabase.k8s.icb4dc0.de_cores.yaml
index 2fd79ef..1f00a24 100644
--- a/config/crd/bases/supabase.k8s.icb4dc0.de_cores.yaml
+++ b/config/crd/bases/supabase.k8s.icb4dc0.de_cores.yaml
@@ -5353,11 +5353,48 @@ spec:
             properties:
               database:
                 properties:
-                  appliedMigrations:
-                    additionalProperties:
-                      format: date-time
-                      type: string
-                    type: object
+                  migrationConditions:
+                    items:
+                      properties:
+                        hash:
+                          description: Hash - SHA256 hash of the script when it was
+                            last successfully applied
+                          format: byte
+                          type: string
+                        lastProbeTime:
+                          description: LastProbeTime - last time the operator tried
+                            to execute the migration script
+                          format: date-time
+                          type: string
+                        lastTransitionTime:
+                          description: LastTransitionTime - last time the condition
+                            transitioned from one status to another
+                          format: date-time
+                          type: string
+                        message:
+                          description: Message - human-readable message indicating
+                            details about the last transition
+                          type: string
+                        name:
+                          description: Name - file name of the migration script
+                          type: string
+                        reason:
+                          description: Reason - one-word, CamcelCase reason for the
+                            condition's last transition
+                          type: string
+                        status:
+                          description: Status - whether the migration was applied
+                            or not
+                          enum:
+                          - Applied
+                          - Failed
+                          type: string
+                      required:
+                      - hash
+                      - name
+                      - status
+                      type: object
+                    type: array
                   roles:
                     additionalProperties:
                       format: byte
diff --git a/internal/db/migrator.go b/internal/db/migrator.go
index d0535b3..c1acce2 100644
--- a/internal/db/migrator.go
+++ b/internal/db/migrator.go
@@ -32,7 +32,7 @@ type Migrator struct {
 	Conn *pgx.Conn
 }
 
-func (m Migrator) ApplyAll(ctx context.Context, status supabasev1alpha1.MigrationStatus, seq iter.Seq2[migrations.Script, error]) (appliedSomething bool, err error) {
+func (m Migrator) ApplyAll(ctx context.Context, status *supabasev1alpha1.CoreStatus, seq iter.Seq2[migrations.Script, error], areInitScripts bool) (appliedSomething bool, err error) {
 	logger := log.FromContext(ctx)
 
 	for s, err := range seq {
@@ -40,11 +40,14 @@ func (m Migrator) ApplyAll(ctx context.Context, status supabasev1alpha1.Migratio
 			return false, err
 		}
 
-		if status.IsApplied(s.FileName) {
+		if found, upToDate := status.Database.IsMigrationUpToDate(s.FileName, s.Hash); found && upToDate {
+			continue
+		} else if found && !upToDate && areInitScripts {
+			logger.Info("Change in init script was detected - will not apply because init scripts are not idempotent", "file_name", s.FileName)
 			continue
 		}
 
-		logger.Info("Applying missing migration", "filename", s.FileName)
+		logger.Info("Applying missing or outdated migration", "filename", s.FileName)
 		if err := m.Apply(ctx, s.Content); err != nil {
 			return false, err
 		}