diff --git a/ACME_STAGING_IMPLEMENTATION.md b/ACME_STAGING_IMPLEMENTATION.md new file mode 100644 index 00000000..afce3b6a --- /dev/null +++ b/ACME_STAGING_IMPLEMENTATION.md @@ -0,0 +1,95 @@ +# ACME Staging Implementation Summary + +## What Was Added + +Added support for Let's Encrypt staging environment to prevent rate limiting during development and testing. + +## Changes Made + +### 1. Configuration (`backend/internal/config/config.go`) +- Added `ACMEStaging bool` field to `Config` struct +- Reads from `CPM_ACME_STAGING` environment variable + +### 2. Caddy Manager (`backend/internal/caddy/manager.go`) +- Added `acmeStaging bool` field to `Manager` struct +- Updated `NewManager()` to accept `acmeStaging` parameter +- Passes `acmeStaging` to `GenerateConfig()` + +### 3. Config Generation (`backend/internal/caddy/config.go`) +- Updated `GenerateConfig()` signature to accept `acmeStaging bool` +- When `acmeStaging=true`: + - Sets `ca` field to `https://acme-staging-v02.api.letsencrypt.org/directory` + - Applies to both "letsencrypt" and "both" SSL provider modes + +### 4. Route Registration (`backend/internal/api/routes/routes.go`) +- Passes `cfg.ACMEStaging` to `caddy.NewManager()` + +### 5. Docker Compose (`docker-compose.local.yml`) +- Added `CPM_ACME_STAGING=true` environment variable for local development + +### 6. Tests +- Updated all test files to pass new `acmeStaging` parameter +- Added `TestGenerateConfig_ACMEStaging()` to verify behavior +- All tests pass ✅ + +### 7. Documentation +- Created `/docs/acme-staging.md` - comprehensive guide +- Updated `/docs/getting-started.md` - added environment variables section +- Explained rate limits, staging vs production, and troubleshooting + +## Usage + +### Development (Avoid Rate Limits) +```bash +docker run -d \ + -e CPM_ACME_STAGING=true \ + -p 8080:8080 \ + ghcr.io/wikid82/cpmp:latest +``` + +### Production (Real Certificates) +```bash +docker run -d \ + -p 8080:8080 \ + ghcr.io/wikid82/cpmp:latest +``` + +## Verification + +Container logs confirm staging is active: +``` +"ca":"https://acme-staging-v02.api.letsencrypt.org/directory" +``` + +## Benefits + +1. **No Rate Limits**: Test certificate issuance without hitting Let's Encrypt limits +2. **Safe Testing**: Won't affect production certificate quotas +3. **Easy Toggle**: Single environment variable to switch modes +4. **Default Production**: Staging must be explicitly enabled +5. **Well Documented**: Clear guides for users and developers + +## Test Results + +- ✅ All backend tests pass (`go test ./...`) +- ✅ Config generation tests verify staging CA is set +- ✅ Manager tests updated and passing +- ✅ Handler tests updated and passing +- ✅ Integration verified in running container + +## Files Modified + +- `backend/internal/config/config.go` +- `backend/internal/caddy/config.go` +- `backend/internal/caddy/manager.go` +- `backend/internal/api/routes/routes.go` +- `backend/internal/caddy/config_test.go` +- `backend/internal/caddy/manager_test.go` +- `backend/internal/caddy/client_test.go` +- `backend/internal/api/handlers/proxy_host_handler_test.go` +- `docker-compose.local.yml` + +## Files Created + +- `docs/acme-staging.md` - User guide +- `ACME_STAGING_IMPLEMENTATION.md` - This summary diff --git a/backend/internal/api/handlers/import_handler.go b/backend/internal/api/handlers/import_handler.go index 8cb45518..9bded7e6 100644 --- a/backend/internal/api/handlers/import_handler.go +++ b/backend/internal/api/handlers/import_handler.go @@ -58,18 +58,38 @@ func (h *ImportHandler) GetStatus(c *gin.Context) { First(&session).Error if err == gorm.ErrRecordNotFound { - // No DB session, check if there's a mounted Caddyfile available for transient preview + // No pending/reviewing session, check if there's a mounted Caddyfile available for transient preview if h.mountPath != "" { - if _, err := os.Stat(h.mountPath); err == nil { - c.JSON(http.StatusOK, gin.H{ - "has_pending": true, - "session": gin.H{ - "id": "transient", - "state": "transient", - "source_file": h.mountPath, - }, - }) - return + if fileInfo, err := os.Stat(h.mountPath); err == nil { + // Check if this mount has already been committed recently + var committedSession models.ImportSession + err := h.db.Where("source_file = ? AND status = ?", h.mountPath, "committed"). + Order("committed_at DESC"). + First(&committedSession).Error + + // Allow re-import if: + // 1. Never committed before (err == gorm.ErrRecordNotFound), OR + // 2. File was modified after last commit + allowImport := err == gorm.ErrRecordNotFound + if !allowImport && committedSession.CommittedAt != nil { + fileMod := fileInfo.ModTime() + commitTime := *committedSession.CommittedAt + allowImport = fileMod.After(commitTime) + } + + if allowImport { + // Mount file is available for import + c.JSON(http.StatusOK, gin.H{ + "has_pending": true, + "session": gin.H{ + "id": "transient", + "state": "transient", + "source_file": h.mountPath, + }, + }) + return + } + // Mount file was already committed and hasn't been modified, don't offer it again } } c.JSON(http.StatusOK, gin.H{"has_pending": false}) @@ -137,7 +157,27 @@ func (h *ImportHandler) GetPreview(c *gin.Context) { // No DB session found or failed to parse session. Try transient preview from mountPath. if h.mountPath != "" { - if _, err := os.Stat(h.mountPath); err == nil { + if fileInfo, err := os.Stat(h.mountPath); err == nil { + // Check if this mount has already been committed recently + var committedSession models.ImportSession + err := h.db.Where("source_file = ? AND status = ?", h.mountPath, "committed"). + Order("committed_at DESC"). + First(&committedSession).Error + + // Allow preview if: + // 1. Never committed before (err == gorm.ErrRecordNotFound), OR + // 2. File was modified after last commit + allowPreview := err == gorm.ErrRecordNotFound + if !allowPreview && committedSession.CommittedAt != nil { + allowPreview = fileInfo.ModTime().After(*committedSession.CommittedAt) + } + + if !allowPreview { + // Mount file was already committed and hasn't been modified, don't offer preview again + c.JSON(http.StatusNotFound, gin.H{"error": "no pending import"}) + return + } + // Parse mounted Caddyfile transiently transient, err := h.importerservice.ImportFile(h.mountPath) if err != nil { diff --git a/backend/internal/api/handlers/proxy_host_handler_test.go b/backend/internal/api/handlers/proxy_host_handler_test.go index 18d872b8..75dd56e0 100644 --- a/backend/internal/api/handlers/proxy_host_handler_test.go +++ b/backend/internal/api/handlers/proxy_host_handler_test.go @@ -113,7 +113,7 @@ func TestProxyHostErrors(t *testing.T) { // Setup Caddy Manager tmpDir := t.TempDir() client := caddy.NewClient(caddyServer.URL) - manager := caddy.NewManager(client, db, tmpDir, "") + manager := caddy.NewManager(client, db, tmpDir, "", false) // Setup Handler ns := services.NewNotificationService(db) @@ -300,7 +300,7 @@ func TestProxyHostWithCaddyIntegration(t *testing.T) { // Setup Caddy Manager tmpDir := t.TempDir() client := caddy.NewClient(caddyServer.URL) - manager := caddy.NewManager(client, db, tmpDir, "") + manager := caddy.NewManager(client, db, tmpDir, "", false) // Setup Handler ns := services.NewNotificationService(db) diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index fba69b7a..1e9eb5ce 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -155,7 +155,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { // Caddy Manager caddyClient := caddy.NewClient(cfg.CaddyAdminAPI) - caddyManager := caddy.NewManager(caddyClient, db, cfg.CaddyConfigDir, cfg.FrontendDir) + caddyManager := caddy.NewManager(caddyClient, db, cfg.CaddyConfigDir, cfg.FrontendDir, cfg.ACMEStaging) proxyHostHandler := handlers.NewProxyHostHandler(db, caddyManager, notificationService) proxyHostHandler.RegisterRoutes(api) diff --git a/backend/internal/caddy/client_test.go b/backend/internal/caddy/client_test.go index a4bfaf40..368058b5 100644 --- a/backend/internal/caddy/client_test.go +++ b/backend/internal/caddy/client_test.go @@ -30,7 +30,7 @@ func TestClient_Load_Success(t *testing.T) { ForwardPort: 8080, Enabled: true, }, - }, "/tmp/caddy-data", "admin@example.com", "", "") + }, "/tmp/caddy-data", "admin@example.com", "", "", false) err := client.Load(context.Background(), config) require.NoError(t, err) diff --git a/backend/internal/caddy/config.go b/backend/internal/caddy/config.go index eadf6352..f93f8d77 100644 --- a/backend/internal/caddy/config.go +++ b/backend/internal/caddy/config.go @@ -10,7 +10,7 @@ import ( // GenerateConfig creates a Caddy JSON configuration from proxy hosts. // This is the core transformation layer from our database model to Caddy config. -func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string) (*Config, error) { +func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool) (*Config, error) { // Define log file paths // We assume storageDir is like ".../data/caddy/data", so we go up to ".../data/logs" // storageDir is .../data/caddy/data @@ -56,19 +56,27 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin // Configure issuers based on provider preference switch sslProvider { case "letsencrypt": - issuers = append(issuers, map[string]interface{}{ + acmeIssuer := map[string]interface{}{ "module": "acme", "email": acmeEmail, - }) + } + if acmeStaging { + acmeIssuer["ca"] = "https://acme-staging-v02.api.letsencrypt.org/directory" + } + issuers = append(issuers, acmeIssuer) case "zerossl": issuers = append(issuers, map[string]interface{}{ "module": "zerossl", }) default: // "both" or empty - issuers = append(issuers, map[string]interface{}{ + acmeIssuer := map[string]interface{}{ "module": "acme", "email": acmeEmail, - }) + } + if acmeStaging { + acmeIssuer["ca"] = "https://acme-staging-v02.api.letsencrypt.org/directory" + } + issuers = append(issuers, acmeIssuer) issuers = append(issuers, map[string]interface{}{ "module": "zerossl", }) diff --git a/backend/internal/caddy/config_test.go b/backend/internal/caddy/config_test.go index 77b37f54..86616065 100644 --- a/backend/internal/caddy/config_test.go +++ b/backend/internal/caddy/config_test.go @@ -9,7 +9,7 @@ import ( ) func TestGenerateConfig_Empty(t *testing.T) { - config, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "admin@example.com", "", "") + config, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "admin@example.com", "", "", false) require.NoError(t, err) require.NotNil(t, config) require.NotNil(t, config.Apps.HTTP) @@ -31,7 +31,7 @@ func TestGenerateConfig_SingleHost(t *testing.T) { }, } - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "") + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false) require.NoError(t, err) require.NotNil(t, config) require.NotNil(t, config.Apps.HTTP) @@ -71,7 +71,7 @@ func TestGenerateConfig_MultipleHosts(t *testing.T) { }, } - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "") + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false) require.NoError(t, err) require.Len(t, config.Apps.HTTP.Servers["cpm_server"].Routes, 2) } @@ -88,7 +88,7 @@ func TestGenerateConfig_WebSocketEnabled(t *testing.T) { }, } - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "") + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false) require.NoError(t, err) route := config.Apps.HTTP.Servers["cpm_server"].Routes[0] @@ -109,7 +109,7 @@ func TestGenerateConfig_EmptyDomain(t *testing.T) { }, } - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "") + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false) require.NoError(t, err) // Should produce empty routes (or just catch-all if frontendDir was set, but it's empty here) require.Empty(t, config.Apps.HTTP.Servers["cpm_server"].Routes) @@ -117,7 +117,7 @@ func TestGenerateConfig_EmptyDomain(t *testing.T) { func TestGenerateConfig_Logging(t *testing.T) { hosts := []models.ProxyHost{} - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "") + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false) require.NoError(t, err) // Verify logging configuration @@ -155,7 +155,7 @@ func TestGenerateConfig_Advanced(t *testing.T) { }, } - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "") + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false) require.NoError(t, err) require.NotNil(t, config) @@ -188,5 +188,48 @@ func TestGenerateConfig_Advanced(t *testing.T) { // Check HSTS hstsHandler := mainRoute.Handle[0] require.Equal(t, "headers", hstsHandler["handler"]) +} + +func TestGenerateConfig_ACMEStaging(t *testing.T) { + hosts := []models.ProxyHost{ + { + UUID: "test-uuid", + DomainNames: "test.example.com", + ForwardHost: "app", + ForwardPort: 8080, + Enabled: true, + }, + } + + // Test with staging enabled + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", true) + require.NoError(t, err) + require.NotNil(t, config.Apps.TLS) + require.NotNil(t, config.Apps.TLS.Automation) + require.Len(t, config.Apps.TLS.Automation.Policies, 1) + + issuers := config.Apps.TLS.Automation.Policies[0].IssuersRaw + require.Len(t, issuers, 1) + + acmeIssuer := issuers[0].(map[string]interface{}) + require.Equal(t, "acme", acmeIssuer["module"]) + require.Equal(t, "admin@example.com", acmeIssuer["email"]) + require.Equal(t, "https://acme-staging-v02.api.letsencrypt.org/directory", acmeIssuer["ca"]) + + // Test with staging disabled (production) + config, err = GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", false) + require.NoError(t, err) + require.NotNil(t, config.Apps.TLS) + require.NotNil(t, config.Apps.TLS.Automation) + require.Len(t, config.Apps.TLS.Automation.Policies, 1) + + issuers = config.Apps.TLS.Automation.Policies[0].IssuersRaw + require.Len(t, issuers, 1) + + acmeIssuer = issuers[0].(map[string]interface{}) + require.Equal(t, "acme", acmeIssuer["module"]) + require.Equal(t, "admin@example.com", acmeIssuer["email"]) + _, hasCA := acmeIssuer["ca"] + require.False(t, hasCA, "Production mode should not set ca field (uses default)") // We can't easily check the map content without casting, but we know it's there. } diff --git a/backend/internal/caddy/manager.go b/backend/internal/caddy/manager.go index 3324a4e6..ef19ac5c 100644 --- a/backend/internal/caddy/manager.go +++ b/backend/internal/caddy/manager.go @@ -21,15 +21,17 @@ type Manager struct { db *gorm.DB configDir string frontendDir string + acmeStaging bool } // NewManager creates a configuration manager. -func NewManager(client *Client, db *gorm.DB, configDir string, frontendDir string) *Manager { +func NewManager(client *Client, db *gorm.DB, configDir string, frontendDir string, acmeStaging bool) *Manager { return &Manager{ client: client, db: db, configDir: configDir, frontendDir: frontendDir, + acmeStaging: acmeStaging, } } @@ -56,7 +58,7 @@ func (m *Manager) ApplyConfig(ctx context.Context) error { } // Generate Caddy config - config, err := GenerateConfig(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, sslProvider) + config, err := GenerateConfig(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, sslProvider, m.acmeStaging) if err != nil { return fmt.Errorf("generate config: %w", err) } diff --git a/backend/internal/caddy/manager_test.go b/backend/internal/caddy/manager_test.go index 7a1f9099..61e7de26 100644 --- a/backend/internal/caddy/manager_test.go +++ b/backend/internal/caddy/manager_test.go @@ -45,7 +45,7 @@ func TestManager_ApplyConfig(t *testing.T) { // Setup Manager tmpDir := t.TempDir() client := NewClient(caddyServer.URL) - manager := NewManager(client, db, tmpDir, "") + manager := NewManager(client, db, tmpDir, "", false) // Create a host host := models.ProxyHost{ @@ -82,7 +82,7 @@ func TestManager_ApplyConfig_Failure(t *testing.T) { // Setup Manager tmpDir := t.TempDir() client := NewClient(caddyServer.URL) - manager := NewManager(client, db, tmpDir, "") + manager := NewManager(client, db, tmpDir, "", false) // Create a host host := models.ProxyHost{ @@ -117,7 +117,7 @@ func TestManager_Ping(t *testing.T) { defer caddyServer.Close() client := NewClient(caddyServer.URL) - manager := NewManager(client, nil, "", "") + manager := NewManager(client, nil, "", "", false) err := manager.Ping(context.Background()) assert.NoError(t, err) @@ -136,7 +136,7 @@ func TestManager_GetCurrentConfig(t *testing.T) { defer caddyServer.Close() client := NewClient(caddyServer.URL) - manager := NewManager(client, nil, "", "") + manager := NewManager(client, nil, "", "", false) config, err := manager.GetCurrentConfig(context.Background()) assert.NoError(t, err) @@ -161,7 +161,7 @@ func TestManager_RotateSnapshots(t *testing.T) { require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{})) client := NewClient(caddyServer.URL) - manager := NewManager(client, db, tmpDir, "") + manager := NewManager(client, db, tmpDir, "", false) // Create 15 dummy config files for i := 0; i < 15; i++ { @@ -217,7 +217,7 @@ func TestManager_Rollback_Success(t *testing.T) { // Setup Manager tmpDir := t.TempDir() client := NewClient(caddyServer.URL) - manager := NewManager(client, db, tmpDir, "") + manager := NewManager(client, db, tmpDir, "", false) // 1. Apply valid config (creates snapshot) host1 := models.ProxyHost{ diff --git a/backend/internal/caddy/validator_test.go b/backend/internal/caddy/validator_test.go index a93aa389..bbeae9d6 100644 --- a/backend/internal/caddy/validator_test.go +++ b/backend/internal/caddy/validator_test.go @@ -25,7 +25,7 @@ func TestValidate_ValidConfig(t *testing.T) { }, } - config, _ := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "") + config, _ := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false) err := Validate(config) require.NoError(t, err) } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 74f5a633..3d4c5d31 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -18,6 +18,7 @@ type Config struct { ImportCaddyfile string ImportDir string JWTSecret string + ACMEStaging bool } // Load reads env vars and falls back to defaults so the server can boot with zero configuration. @@ -33,6 +34,7 @@ func Load() (Config, error) { ImportCaddyfile: getEnv("CPM_IMPORT_CADDYFILE", "/import/Caddyfile"), ImportDir: getEnv("CPM_IMPORT_DIR", filepath.Join("data", "imports")), JWTSecret: getEnv("CPM_JWT_SECRET", "change-me-in-production"), + ACMEStaging: getEnv("CPM_ACME_STAGING", "") == "true", } if err := os.MkdirAll(filepath.Dir(cfg.DatabasePath), 0o755); err != nil { diff --git a/docs/acme-staging.md b/docs/acme-staging.md new file mode 100644 index 00000000..be4e1dc3 --- /dev/null +++ b/docs/acme-staging.md @@ -0,0 +1,105 @@ +# ACME Staging Environment + +## Overview + +CaddyProxyManager+ supports using Let's Encrypt's staging environment for development and testing. This prevents rate limiting issues when frequently rebuilding/testing SSL certificates. + +## Configuration + +Set the `CPM_ACME_STAGING` environment variable to `true` to enable staging mode: + +```bash +export CPM_ACME_STAGING=true +``` + +Or in Docker Compose: + +```yaml +environment: + - CPM_ACME_STAGING=true +``` + +## What It Does + +When enabled: +- Caddy will use `https://acme-staging-v02.api.letsencrypt.org/directory` instead of production +- Certificates issued will be **fake/invalid** for browsers (untrusted) +- **No rate limits** apply to staging certificates +- Perfect for development, testing, and CI/CD + +## Production Use + +For production deployments: +- **Remove** or set `CPM_ACME_STAGING=false` +- Caddy will use the production Let's Encrypt server by default +- Certificates will be valid and trusted by browsers +- Subject to [Let's Encrypt rate limits](https://letsencrypt.org/docs/rate-limits/) + +## Docker Compose Examples + +### Development (docker-compose.local.yml) +```yaml +services: + app: + environment: + - CPM_ENV=development + - CPM_ACME_STAGING=true # Use staging for dev +``` + +### Production (docker-compose.yml) +```yaml +services: + app: + environment: + - CPM_ENV=production + # CPM_ACME_STAGING not set (defaults to false) +``` + +## Verifying Configuration + +Check container logs to confirm staging is active: + +```bash +docker logs cpmp 2>&1 | grep acme-staging +``` + +You should see: +``` +"ca":"https://acme-staging-v02.api.letsencrypt.org/directory" +``` + +## Rate Limits Reference + +### Production (CPM_ACME_STAGING=false or unset) +- 50 certificates per registered domain per week +- 5 duplicate certificates per week +- 300 new orders per account per 3 hours +- 10 accounts per IP address per 3 hours + +### Staging (CPM_ACME_STAGING=true) +- **No practical rate limits** +- Certificates are not trusted by browsers +- Perfect for development and testing + +## Troubleshooting + +### "Certificate not trusted" in browser +This is **expected** when using staging. Staging certificates are signed by a fake CA that browsers don't recognize. + +### Switching from staging to production +1. Set `CPM_ACME_STAGING=false` (or remove the variable) +2. Restart the container +3. Delete the old staging certificates: `docker exec cpmp rm -rf /app/data/caddy/data/acme/acme-staging*` +4. Certificates will be automatically reissued from production + +### Switching from production to staging +1. Set `CPM_ACME_STAGING=true` +2. Restart the container +3. Optionally delete old production certificates to force immediate reissue + +## Best Practices + +1. **Always use staging for local development** to avoid hitting rate limits +2. Use production in CI/CD pipelines that test actual certificate validation +3. Document your environment variable settings in your deployment docs +4. Monitor Let's Encrypt rate limit emails in production diff --git a/docs/getting-started.md b/docs/getting-started.md index 6a1abe59..de199500 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -188,6 +188,44 @@ The app stores everything in a database. The Docker command above saves it in `c --- +## ⚙️ Environment Variables (Advanced) + +Want to customize how the app runs? You can set these options: + +### Common Options + +| Variable | Default | What It Does | +|----------|---------|--------------| +| `CPM_ENV` | `development` | Set to `production` for live use | +| `CPM_HTTP_PORT` | `8080` | Change the web interface port | +| `CPM_ACME_STAGING` | `false` | Use Let's Encrypt staging (see below) | + +### 🧪 Development Mode: ACME Staging + +**Problem:** Testing SSL certificates repeatedly can hit Let's Encrypt rate limits (50 certs/week) + +**Solution:** Use staging mode for development! + +```bash +docker run -d \ + -p 8080:8080 \ + -e CPM_ACME_STAGING=true \ + -v caddy_data:/app/data \ + --name caddy-proxy-manager \ + ghcr.io/wikid82/cpmp:latest +``` + +**What happens:** +- ✅ No rate limits +- ⚠️ Certificates are "fake" (untrusted by browsers) +- Perfect for testing + +**For production:** Remove `CPM_ACME_STAGING` or set to `false` + +📖 **Learn more:** [ACME Staging Guide](acme-staging.md) + +--- + ## 🐛 Something Not Working? ### App Won't Start @@ -205,6 +243,11 @@ The app stores everything in a database. The Docker command above saves it in `c - **Look at the error message** - it usually tells you what's wrong - **Start with a simple file** - test with just one site first +### Hit Let's Encrypt Rate Limit +- **Use staging mode** - set `CPM_ACME_STAGING=true` (see above) +- **Wait a week** - limits reset weekly +- **Check current limits** - visit [Let's Encrypt Status](https://letsencrypt.status.io/) + --- ## 📚 What's Next?