Skip to content

Strapi Security & Monitoring — Hardening, Logging, Error Tracking, and Backups

DodaTech Updated 2026-06-28 12 min read

In this tutorial, you will learn how to harden Strapi for production security and set up comprehensive monitoring — implementing access controls, audit logging, error tracking with Sentry, health check endpoints, and automated backup and recovery procedures.

What You'll Learn

  • How to harden Strapi against common security vulnerabilities
  • How to implement audit logging for content changes
  • How to set up error tracking with Sentry
  • How to create health check endpoints
  • How to configure automated database backups
  • How to set up uptime monitoring and alerts

Why It Matters

Production Strapi applications are targets for attackers. A compromised admin panel means attackers can delete content, steal user data, or inject malicious code into your API responses. Without monitoring, you do not know when something breaks — your API can be down for hours before anyone notices. Security hardening and monitoring are not optional for production systems. They protect your data, your users, and your reputation.

Real-World Use

A media company's Strapi backend suffered a brute-force attack on the admin panel. Because the team had implemented rate limiting, IP-based access control, and audit logging, the attack was detected and blocked within minutes. Audit logs showed the attacker attempted 10,000 login attempts from a single IP. The team blocked the IP, reviewed the logs to confirm no unauthorized access occurred, and implemented additional security measures. The monitoring system alerted the team within 30 seconds of the attack starting.

Learning Path

flowchart LR
  A["Performance"] --> B["Security & Monitoring
-- You are here"]:::current B --> C["🎉 Course Complete"] classDef current fill:#4945ff,color:#fff,stroke-width:2px

Security Hardening

Apply these security measures to protect your Strapi instance:

// config/middlewares.js — Security middleware configuration
module.exports = [
  "strapi::logger",
  "strapi::errors",
  {
    name: "strapi::security",
    config: {
      contentSecurityPolicy: {
        useDefaults: true,
        directives: {
          "connect-src": ["'self'", "https:"],
          "img-src": [
            "'self'",
            "data:",
            "blob:",
            "market-assets.strapi.io",
            "cdn.example.com",  // Your CDN domain
          ],
          "media-src": [
            "'self'",
            "data:",
            "blob:",
            "market-assets.strapi.io",
            "cdn.example.com",
          ],
          upgradeInsecureRequests: null,
        },
      },
    },
  },
  {
    name: "strapi::cors",
    config: {
      // Restrict to your frontend domains
      origin: [
        "https://example.com",
        "https://admin.example.com",
        "http://localhost:3000",  // Development
      ],
      methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
      headers: ["Content-Type", "Authorization", "Origin", "Accept"],
      keepHeaderOnError: true,
    },
  },
  "strapi::poweredBy",
  "strapi::query",
  "strapi::body",
  "strapi::session",
  "strapi::favicon",
  "strapi::public",
];
// config/admin.js — Admin panel security
module.exports = ({ env }) => ({
  auth: {
    secret: env("ADMIN_JWT_SECRET"),
    // Lock account after failed attempts
    options: {
      maxLoginAttempts: 5,
      lockoutTime: 15 * 60 * 1000,  // 15 minutes
    },
  },
  // Restrict admin access by IP (if behind nginx)
  url: env("ADMIN_URL", "/admin"),
  serveAdminPanel: true,
  // Force HTTPS for admin cookies
  admin: {
    auth: {
      options: {
        secure: env.bool("ADMIN_HTTPS", true),
        httpOnly: true,
        sameSite: "strict",
      },
    },
  },
});

Restrict admin panel access at the reverse proxy level:

# nginx — Restrict admin panel to trusted IPs
server {
    listen 443 ssl;
    server_name api.example.com;

    # Admin panel — restricted access
    location /admin {
        # Allow only office and VPN IPs
        allow 203.0.113.0/24;  # Office network
        allow 10.0.0.0/8;      # VPN range
        deny all;               # Everyone else blocked

        proxy_pass http://127.0.0.1:1337;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    # API — public access (with rate limiting)
    location /api/ {
        # Rate limiting zone
        limit_req zone=api burst=20 nodelay;
        proxy_pass http://127.0.0.1:1337;
    }
}

Rate Limiting

Protect against brute-force and DDoS attacks:

// config/middlewares.js — Rate limiting
module.exports = [
  // ... other middlewares
  {
    name: "global::rate-limiter",
    config: {},
  },
];
// src/middlewares/rate-limiter.js
const rateLimit = require("koa-ratelimit");
const Redis = require("ioredis");

module.exports = (config, { strapi }) => {
  return rateLimit({
    driver: "redis",
    db: new Redis({
      host: process.env.REDIS_HOST || "localhost",
      port: process.env.REDIS_PORT || 6379,
    }),
    // General API rate limit: 100 requests per minute
    duration: 60 * 1000,
    max: 100,
    // Auth endpoints: stricter limit
    whitelist: (ctx) => {
      if (ctx.path.startsWith("/api/auth")) {
        ctx.state.rateLimit = { max: 10, duration: 60 * 1000 };
      }
      return ctx;
    },
    // Return proper error response
    errorMessage: "Too many requests. Please try again later.",
    headers: {
      remaining: "Rate-Limit-Remaining",
      reset: "Rate-Limit-Reset",
      total: "Rate-Limit-Total",
    },
  });
};

Audit Logging

Track who changed what and when:

// src/middlewares/audit-log.js
module.exports = (config, { strapi }) => {
  return async (ctx, next) => {
    // Only log mutations (create, update, delete)
    const mutations = ["POST", "PUT", "DELETE"];
    if (!mutations.includes(ctx.method)) {
      return await next();
    }

    await next();

    // Log the mutation
    const logEntry = {
      action: `${ctx.method} ${ctx.path}`,
      user: ctx.state.user?.email || "anonymous",
      statusCode: ctx.status,
      timestamp: new Date().toISOString(),
      ip: ctx.ip,
      userAgent: ctx.headers["user-agent"],
    };

    // Store log in database or send to logging service
    strapi.log.info("Audit:", JSON.stringify(logEntry));

    // Store in audit log collection (if configured)
    try {
      await strapi.db.query("plugin::audit-log.audit-log").create({
        data: logEntry,
      });
    } catch (error) {
      // Silent fail — do not block the request
      strapi.log.warn("Audit log store failed:", error.message);
    }
  };
};

Error Tracking with Sentry

Monitor errors in production and get notified when things break:

npm install @sentry/node @sentry/tracing
// config/server.js — Initialize Sentry
const Sentry = require("@sentry/node");

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  environment: process.env.NODE_ENV,
  tracesSampleRate: 0.2,  // Sample 20% of transactions
  integrations: [new Sentry.Integrations.Http({ tracing: true })],
});

module.exports = ({ env }) => ({
  host: env("HOST", "0.0.0.0"),
  port: env.int("PORT", 1337),
  app: { keys: env.array("APP_KEYS") },
});
// src/middlewares/error-tracker.js
const Sentry = require("@sentry/node");

module.exports = (config, { strapi }) => {
  return async (ctx, next) => {
    try {
      await next();
    } catch (error) {
      // Send error to Sentry
      Sentry.withScope((scope) => {
        scope.setExtra("path", ctx.path);
        scope.setExtra("method", ctx.method);
        scope.setUser({ ip: ctx.ip });
        Sentry.captureException(error);
      });

      // Re-throw for Strapi to handle the response
      throw error;
    }
  };
};

Health Check Endpoint

Create an endpoint for monitoring services:

// src/api/health-check/controllers/health-check.js
module.exports = {
  async check(ctx) {
    const healthStatus = {
      status: "ok",
      timestamp: new Date().toISOString(),
      uptime: process.uptime(),
      checks: {
        database: false,
        redis: false,
        storage: false,
      },
    };

    // Check database connection
    try {
      await strapi.db.connection.raw("SELECT 1+1 AS result");
      healthStatus.checks.database = true;
    } catch (error) {
      healthStatus.status = "degraded";
      healthStatus.checks.database = false;
    }

    // Check Redis connection
    try {
      // If using Redis
      healthStatus.checks.redis = true;
    } catch (error) {
      healthStatus.status = "degraded";
    }

    // Check file storage
    try {
      if (strapi.plugins.upload) {
        healthStatus.checks.storage = true;
      }
    } catch (error) {
      healthStatus.status = "degraded";
    }

    // Return 503 if any critical check fails
    const statusCode = healthStatus.status === "ok" ? 200 : 503;

    ctx.body = healthStatus;
    ctx.status = statusCode;
  },
};
// src/api/health-check/routes/health-check.js
module.exports = {
  routes: [
    {
      method: "GET",
      path: "/health",
      handler: "health-check.check",
      config: {
        policies: [],
        auth: false,  // Public endpoint
      },
    },
  ],
};

Uptime Monitoring

Configure external monitoring to detect downtime:

# Using UptimeRobot, Better Uptime, or Pingdom:
# Monitor: GET https://api.example.com/health
# Expected: HTTP 200 with JSON body {"status": "ok"}
# Alert: Email + Slack + SMS if 2 consecutive checks fail

# Self-hosted option with cron:
# crontab -e
# */5 * * * * curl -f https://api.example.com/health || echo "Strapi down!" | mail -s "Strapi Alert" admin@example.com
// Monitor from a script
// scripts/health-check.js
const https = require("https");

const options = {
  hostname: "api.example.com",
  path: "/api/health",
  method: "GET",
  timeout: 10000,
};

const req = https.request(options, (res) => {
  let data = "";

  res.on("data", (chunk) => {
    data += chunk;
  });

  res.on("end", () => {
    const status = JSON.parse(data);
    if (status.status !== "ok") {
      console.error("Health check failed:", status);
      process.exit(1);
    }
    console.log("Health check passed");
    process.exit(0);
  });
});

req.on("error", (error) => {
  console.error("Health check error:", error.message);
  process.exit(1);
});

req.end();

Automated Backups

Set up regular backups with retention policies:

#!/bin/bash
# scripts/backup.sh — Automated Strapi backup

BACKUP_DIR="/backups/strapi"
DB_NAME="strapi_production"
TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S)
RETENTION_DAYS=7

mkdir -p "$BACKUP_DIR"

# Backup database
echo "Backing up database..."
pg_dump "$DB_NAME" | gzip > "$BACKUP_DIR/db-$TIMESTAMP.sql.gz"

# Backup uploads
echo "Backing up uploads..."
tar -czf "$BACKUP_DIR/uploads-$TIMESTAMP.tar.gz" public/uploads/

# Clean old backups (older than RETENTION_DAYS)
find "$BACKUP_DIR" -name "db-*.sql.gz" -mtime +$RETENTION_DAYS -delete
find "$BACKUP_DIR" -name "uploads-*.tar.gz" -mtime +$RETENTION_DAYS -delete

echo "Backup complete: $BACKUP_DIR"
echo "  Database: db-$TIMESTAMP.sql.gz"
echo "  Uploads: uploads-$TIMESTAMP.tar.gz"

# Optional: Upload to remote storage (S3, Backblaze, etc.)
# aws s3 sync "$BACKUP_DIR" s3://strapi-backups/
# crontab — Run backup daily at 2 AM
0 2 * * * /opt/strapi/scripts/backup.sh >> /var/log/strapi-backup.log 2>&1

Restore from backup:

#!/bin/bash
# scripts/restore.sh — Restore Strapi from backup

BACKUP_FILE="$1"
UPLOADS_FILE="$2"

if [ -z "$BACKUP_FILE" ] || [ -z "$UPLOADS_FILE" ]; then
  echo "Usage: $0 <db-backup.sql.gz> <uploads-backup.tar.gz>"
  exit 1
fi

echo "Restoring database..."
gunzip -c "$BACKUP_FILE" | psql strapi_production

echo "Restoring uploads..."
tar -xzf "$UPLOADS_FILE" -C /

echo "Restore complete."

Logging Best Practices

Configure structured logging for better analysis:

// config/server.js — Logging configuration
module.exports = ({ env }) => ({
  // ... other config
  logger: {
    // Use JSON format in production for log aggregators
    format: env("NODE_ENV") === "production" ? "json" : "pretty",
    level: env("LOG_LEVEL", "info"),
    exposeInContext: true,
    // Log to file
    transports: [
      {
        target: "pino/file",
        options: {
          destination: env("LOG_FILE", "./logs/strapi.log"),
          mkdir: true,
        },
      },
    ],
  },
});

Set up log rotation to prevent disk overflow:

# /etc/logrotate.d/strapi
/opt/strapi/logs/*.log {
    daily
    rotate 14
    compress
    delaycompress
    missingok
    notifempty
    copytruncate
}

Security Checklist

[ ] CORS restricted to known frontend domains
[ ] Rate limiting enabled (100 req/min general, 10 req/min auth)
[ ] Admin panel access restricted by IP (if possible)
[ ] SSL/TLS enabled (no HTTP access)
[ ] Database SSL connection enabled
[ ] APP_KEYS, JWT secrets set to unique production values
[ ] Admin JWT secret rotated from default
[ ] Content Security Policy headers configured
[ ] File upload size limits set
[ ] Allowed upload file types restricted
[ ] Public API does not expose internal IDs unnecessarily
[ ] Audit logging enabled for all mutations
[ ] Error tracking (Sentry) configured
[ ] Health check endpoint enabled
[ ] Automated backups configured with retention
[ ] Log rotation configured
[ ] Monitoring alerts configured (email/Slack/SMS)
[ ] Strapi and plugins updated to latest versions
[ ] Database access restricted to Strapi server IP
[ ] Principle of least privilege applied to user roles

Common Mistakes

  1. Exposing the admin panel to the internet without IP restriction. Attackers can attempt to brute-force the admin login. Restrict admin access to trusted IPs or use a VPN. If IP restriction is not possible, enforce strong passwords and rate limiting.

  2. Not monitoring for downtime. Without monitoring, your API could be down for hours before anyone notices. Set up uptime monitoring with alerts. Even a free tier of UptimeRobot or Better Uptime catches downtime within minutes.

  3. Skipping database backups. A corrupted database, accidental deletion, or ransomware attack without backups means permanent data loss. Set up automated daily backups with off-site storage. Test restoring from backups monthly.

  4. Using default Strapi secrets in production. Strapi generates secrets during installation. Attackers know the default values. Generate new APP_KEYS, JWT_SECRET, ADMIN_JWT_SECRET, and API_TOKEN_SALT for production.

  5. Ignoring Strapi and plugin updates. Outdated Strapi versions contain known vulnerabilities. Subscribe to Strapi security advisories and update promptly. Test updates in a staging environment before applying to production.

Practice Questions

  1. What security measures should you apply to the Strapi admin panel? Answer: IP restriction (allow only trusted IPs or VPN), rate limiting on login (max 5 attempts, 15-minute lockout), strong password policy, HTTPS-only cookies, and audit logging of all admin actions.

  2. Why should you use structured logging (JSON format) in production? Answer: JSON logs are parseable by log aggregation tools (ELK Stack, Datadog, Splunk). They enable searching, filtering, and alerting on specific events. Pretty-printed logs are only useful for local development.

  3. What should a health check endpoint verify? Answer: Database connectivity, Redis connectivity (if used), file storage accessibility, and overall application status. It should return 200 if all checks pass and 503 if critical services are unavailable.

  4. Challenge: Implement a complete security and monitoring system: (1) Harden the admin panel with IP restriction and rate limiting, (2) Configure CORS to allow only your frontend domain, (3) Set up audit logging that logs all create, update, and delete operations with user info and timestamps, (4) Integrate Sentry for error tracking, (5) Create a health check endpoint that verifies database and file storage, (6) Set up automated daily backups with 7-day retention, (7) Configure log rotation to prevent disk overflow, (8) Set up uptime monitoring with Slack alerts, (9) Write an incident response plan document.

FAQ

How do I know if my Strapi instance has been compromised?

Check audit logs for unauthorized login attempts, review content changes for unexpected modifications, check user accounts for new admin users, monitor API traffic for unusual patterns, and review server logs for suspicious requests.

Should I use a Web Application Firewall (WAF) for Strapi?

Yes, a WAF like Cloudflare, AWS WAF, or ModSecurity adds an additional security layer. It blocks SQL injection, XSS, and other common attack patterns before they reach Strapi.

How often should I update Strapi and plugins?

Check for updates weekly. Apply security patches immediately. For major version upgrades, wait 2-4 weeks for community testing, then upgrade in staging first. Subscribe to the Strapi security mailing list.

What is the best way to store audit logs?

Store audit logs in a separate database or a log aggregation service (ELK, Datadog). Do not store them in the Strapi database — if the database is compromised, the logs are too. Send logs to a read-only or append-only system.

How do I handle a security incident?
  1. Identify the breach and contain it (take the server offline if needed), 2) Preserve logs and evidence, 3) Assess the damage (what data was accessed or modified), 4) Notify affected users if personal data was compromised, 5) Fix the vulnerability, 6) Restore from clean backups, 7) Document the incident and update security procedures.

Mini Project

Your task: Secure and monitor a production Strapi deployment.

  1. Audit your Strapi configuration against the security checklist above.
  2. Fix any issues found in the audit.
  3. Set up audit logging and verify it captures create, update, and delete operations.
  4. Integrate Sentry for error tracking and trigger a test error to confirm it works.
  5. Create a health check endpoint and verify it with curl.
  6. Set up automated daily backups with a retention policy.
  7. Test the backup restore Process.
  8. Configure uptime monitoring with alerts.
  9. Write a one-page incident response plan.
  10. Document all security and monitoring configurations for the operations team.

What's Next

Congratulations! You have completed the Strapi tutorial series. You started with the fundamentals of headless CMS and Strapi architecture, learned to model content with collection types and components, exposed it through REST and Graphql APIs, configured users and permissions, managed media files, built custom plugins, and deployed to production with security and monitoring.

From here, continue your learning journey:

  • Explore WordPress to compare traditional CMS with headless Strapi
  • Learn GraphQL for advanced API querying techniques
  • Deepen your Node.js skills for custom Strapi development
  • Check the MySQL and PostgreSQL guides for database administration

Related lessons:

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro