Strapi Security & Monitoring — Hardening, Logging, Error Tracking, and Backups
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
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.
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.
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.
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.
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
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.
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.
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.
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
Mini Project
Your task: Secure and monitor a production Strapi deployment.
- Audit your Strapi configuration against the security checklist above.
- Fix any issues found in the audit.
- Set up audit logging and verify it captures create, update, and delete operations.
- Integrate Sentry for error tracking and trigger a test error to confirm it works.
- Create a health check endpoint and verify it with curl.
- Set up automated daily backups with a retention policy.
- Test the backup restore Process.
- Configure uptime monitoring with alerts.
- Write a one-page incident response plan.
- 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:
- WordPress Security — Security comparison
- Node.js — Server-side JavaScript
- REST API Security — Securing APIs
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro