Areca Docs

Production setup

Prepare an Areca CMS deployment for real use, covering HTTPS, public URLs, locked-down ports, real services, backups and dev-only switches.

Use this checklist after the Docker quickstart works on your machine. The Compose file is built for local trials. It uses demo credentials, a test inbox, and a CMS that is reachable only from the host. A production instance needs HTTPS, real URLs, real secrets and services, and a backup plan.

Work through the sections in order:

  1. Put the CMS behind HTTPS
  2. Set the public URLs
  3. Keep the CMS bound to localhost
  4. Replace the demo services
  5. Only listed variables reach the container
  6. Run a single CMS replica
  7. Allow the Dokploy host to connect
  8. Turn off dev-only switches
  9. Protect the secrets and back up the instance
  10. Watch the health endpoints

Put the CMS behind HTTPS

The CMS listens on plain HTTP inside the container. Put a reverse proxy in front of it on the same host to terminate TLS and serve your public domain. The proxy forwards to 127.0.0.1:3000, the address the Compose file binds to.

The proxy must do three things:

  • Forward the original Host header. Sign-in and form submissions are checked against BETTER_AUTH_URL and APP_URL, so a request that arrives under another address is blocked.
  • Send X-Forwarded-Proto so the CMS knows the request came in over HTTPS.
  • Forward every path to the CMS, including /.well-known/ (AI apps find the sign-in server there), /api/auth/ (sign-in) and /api/mcp/ (the connector URL). A proxy that forwards only some paths breaks AI app sign-in.
  • Replace X-Forwarded-For with the real client address instead of adding to it. When RATE_LIMIT_TRUST_PROXY is on, the CMS takes the client address from the first value in that header, and falls back to X-Real-IP or CF-Connecting-IP. If the proxy appends to the header, a visitor can send a fake first value and dodge the limit.

Caddy does all of this with automatic certificates:

Caddyfile
cms.example.com {
    reverse_proxy 127.0.0.1:3000 {
        header_up Host {host}
        header_up X-Forwarded-For {remote_host}
        header_up X-Forwarded-Proto {scheme}
    }
}

nginx needs an existing certificate, which you point to with ssl_certificate and ssl_certificate_key:

/etc/nginx/conf.d/areca-cms.conf
server {
    listen 443 ssl;
    server_name cms.example.com;

    ssl_certificate     /etc/letsencrypt/live/cms.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/cms.example.com/privkey.pem;

    # Uploads can be up to 20 MiB; nginx rejects larger bodies by default at 1 MB.
    client_max_body_size 25m;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Once the proxy is in front of the CMS, set RATE_LIMIT_TRUST_PROXY=true so rate limits apply per visitor. Without it, the CMS sees only the proxy's address, and all anonymous visitors share one rate-limit bucket. The stock Compose file does not pass this variable, so add it as described in only listed variables reach the container. For the limits themselves, see rate limits.

Set the public URLs

Three variables must match the address people use:

VariableCompose defaultProduction valueWhy it matters
CMS_URL in .envhttp://localhost:3000https://cms.example.comFeeds BETTER_AUTH_URL and APP_URL in the Compose file.
BETTER_AUTH_URL${CMS_URL}https://cms.example.comUsed for sign-in callbacks and social or SSO login redirects. It is also the OAuth issuer, https://cms.example.com/api/auth, that AI apps sign in with. A mismatch breaks sign-in.
APP_URL${CMS_URL}https://cms.example.comUsed in invitation links, uploaded file URLs and the connector URL of each site (/api/mcp/<site>). Browsers and the Dokploy host must be able to reach it.

Set CMS_URL in .env to the public HTTPS address, and the Compose file passes it to both variables. Do not use localhost or a private LAN address for APP_URL. A site build on the Dokploy host would fetch content from its own localhost, which fails.

Keep APP_URL and BETTER_AUTH_URL on the same public origin. AI apps read the sign-in server from the connector URL's protected resource document, and then sign in there, so the two must agree.

AI apps need public HTTPS

ChatGPT and Claude connect from the internet, so a CMS that is reachable only on a local network cannot be used with them. Claude Code, Cursor and VS Code also require HTTPS for any address except localhost, so they cannot sign in to a CMS on a plain http:// LAN address. After you deploy, check the two discovery documents. Replace cms.example.com and acme-site with your values:

Check the discovery documents
curl -s https://cms.example.com/.well-known/oauth-protected-resource/api/mcp/acme-site
# "resource" is https://cms.example.com/api/mcp/acme-site and
# "authorization_servers" is ["https://cms.example.com/api/auth"]
curl -s https://cms.example.com/.well-known/oauth-authorization-server/api/auth
# "issuer" is https://cms.example.com/api/auth

If either request returns a 404, or the values use another origin, check APP_URL, BETTER_AUTH_URL and the proxy's paths.

Uploaded files are served from the CMS at /api/assets/file/... and their URLs contain APP_URL. Existing published releases keep the URLs they were built with. After you change APP_URL, go live again so new releases use the new address.

Keep the CMS bound to localhost

The Compose file publishes the CMS port as 127.0.0.1:${CMS_PORT}:3000. Only processes on the same host can connect to it, which is what you want when the proxy runs on that host. The Compose file also binds the storage console and the Mailpit inbox to 127.0.0.1.

For production:

  • Keep the proxy on the same host and leave the binding as it is. This is the simplest and safest setup.
  • If the proxy runs on a different machine, change the host side of the CMS port mapping to this host's private network address, and restrict that port with a firewall so only the proxy can reach it. Do not change it to 0.0.0.0 without a firewall.
  • Remove the rustfs and mailpit port mappings from your production copy, and remove the mailpit service entirely (see the next section). The database, Redis and the S3 API have no host ports in the Compose file, so keep it that way.

On the public internet, expose only the proxy: port 443 for HTTPS, and port 80 if your certificate setup needs it for challenges and redirects.

Replace the demo services

The Compose file hard-codes several connections for the local stack. Production needs real values for all of them:

Setting in compose.ymlLocal valueReplace with
S3_ENDPOINThttp://rustfs:9000The URL of your S3-compatible storage
S3_REGIONus-east-1Your bucket's region
S3_BUCKETarecaA private dedicated bucket, or an existing shared bucket with a separate installation prefix
S3_PREFIXemptyOptional installation path such as areca/production; Compose passes it from .env
S3_ACCESS_KEY, S3_SECRET_KEYfrom .envCredentials with the required bucket checks and object permissions; restrict access through provider policies
S3_FORCE_PATH_STYLE"true""true" for MinIO and RustFS, "false" for AWS S3 and most hosted services
SMTP_HOST, SMTP_PORTmailpit, 1025Your mail provider's host and port, or unset if you set up mail in Agency › Mail
SMTP_FROMcms@areca.localA sender address on a domain you control

Mailpit does not send email. It captures messages so you can read them. In production, invitations must reach real inboxes, so remove the mailpit service and its entry in the cms service's depends_on list. Then set up a mail server in one of two ways:

  • Use environment variables. Set SMTP_HOST, SMTP_PORT, SMTP_FROM, SMTP_USER, SMTP_PASSWORD and, if needed, SMTP_SECURE and SMTP_REPLY_TO in docker/.env. The stock compose.yml passes all of them to the CMS and falls back to Mailpit only when SMTP_HOST is unset.
  • Set it up in the CMS. Remove the SMTP_* lines from your production copy of compose.yml. The CMS then starts with mail off. Sign in as the first admin, and enter the server under Agency › Mail. Settings saved there replace the environment settings.

The provider sections of storage and email list the host, port and sign-in details for Resend, Postmark, Amazon SES, Mailgun and Google Workspace. Also check the deliverability steps before you invite clients.

If you use external storage, also remove the rustfs service, its rustfs_data volume and its entry in depends_on. The same applies to postgres and redis if you run them as managed services, but then the CMS needs the matching DATABASE_URL and REDIS_URL values.

Use a mail provider that requires sign-in, with a verified sending domain, or a relay on your private network. Do not point Areca at an open relay on the internet. See storage and email for the provider settings and the SPF, DKIM and DMARC records.

The bucket must stay private. The CMS reads and writes it, and serves uploaded files to visitors itself. Do not make the bucket public. Anything uploaded to the Library is public by design, so do not upload confidential files. See asset retention for how deleted files are handled.

A prefix scopes object requests, not the credentials themselves. Startup/readiness still check the bucket, without probing prefix permissions. Before changing an existing installation's prefix, follow the manual migration and rollback steps.

Keep the database and Redis volumes on disks you back up. The Compose file stores them in named volumes called postgres_data and redis_data.

Only listed variables reach the container

Docker Compose reads .env only to fill in the ${...} placeholders inside compose.yml. It passes a variable to the CMS container only if the cms service lists it under environment. Setting a variable in .env is not enough.

This catches many operators out. For example, this line in .env does nothing:

WORKER_CONCURRENCY=4   # not passed to the container

The Compose file currently passes these optional settings to the CMS: the SMTP_* mail settings, SECRETS_ENCRYPTION_KEY, AI_SETTINGS_ENCRYPTION_KEY, SETUP_TOKEN, METRICS_TOKEN and the database and readiness timeouts. These settings are not passed, so setting them in .env has no effect:

  • WORKER_ENABLED, WORKER_CONCURRENCY, WORKER_DRAIN_TIMEOUT_MS
  • LOG_LEVEL
  • ADMIN_MFA_REQUIRED, AUTH_EMAIL_PASSWORD_ENABLED
  • RATE_LIMIT_TRUST_PROXY
  • The GitHub, Google, Discord and Microsoft sign-in settings
  • The Dokploy settings, including DOKPLOY_URL and DOKPLOY_API_KEY

To pass one, add it to the cms service's environment list in your production copy of compose.yml. For example:

docker/compose.yml (cms service, production copy)
    environment:
      LOG_LEVEL: ${LOG_LEVEL:-info}
      RATE_LIMIT_TRUST_PROXY: "true"
      GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
      GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}

Use ${NAME:-} for optional values so an unset value stays blank and the CMS treats it as not set. Keep the variable names in the list in sync with the configuration reference.

Run a single CMS replica

Run one CMS container. Do not use docker compose up --scale cms=2 or add a second CMS to scale traffic.

The CMS was sized and tested for one replica. The publish worker and the scheduler run inside each CMS process, and the project has not tested several processes against one database. The capacity page lists the supported limits.

If you need more capacity, first tune the single instance: give the container more memory, reduce retained publications and review the benchmark. Scaling out is not a supported setup today.

Allow the Dokploy host to connect

The Dokploy host builds customer sites and runs their health checks, so it must reach the CMS, and the CMS must reach it back. Everything except browser traffic goes over HTTPS.

FromToUsed for
BrowsersCMS, at https://cms.example.comEditing, previews and sign-in
Dokploy hostCMS, at APP_URLThe site build fetches the content snapshot, and each site fetches its uploaded images
CMSDokploy API, at DOKPLOY_URL or the URL saved for the clientDeploying, reading deployment status and logs
CMSSite health URL, such as https://<site-host>/api/healthConfirming that the new release is live before the CMS marks it live
CMSMail server (SMTP)Sending email, such as invitations
CMSS3-compatible storageUploaded files and branding
CMSOpenRouter, openrouter.aiOnly if you use AI SEO suggestions

Check these before the first go-live:

  • The CMS public address resolves to the proxy and returns a valid certificate.
  • The Dokploy host can open https://cms.example.com/api/health and get a successful response.
  • The site health URL is a public HTTPS address. The CMS refuses private-address health checks unless the dev-only switch is on, so do not work around this by enabling that switch in production.

The deployment targets page explains how to connect Dokploy and set the health URL for each site.

Turn off dev-only switches

Some settings exist only so a local or LAN test setup can talk to Dokploy. Keep all of them off on a public instance:

VariableEffectProduction value
DOKPLOY_ALLOW_INSECURE_HTTPAllows plain http:// Dokploy API URLs.Unset. Use an https:// Dokploy URL.
ALLOW_PRIVATE_HEALTH_CHECKSAllows health checks against private or local addresses.Unset. Use a public HTTPS health URL.
DOKPLOY_DEV_FALLBACK_ENABLEDEnables a legacy server-wide Dokploy connection. The production image ignores it, but do not set it.Unset or false
ADMIN_MFA_REQUIREDRequires two-factor authentication for admins. Defaults to true.true, or unset

The Compose file passes none of these to the container, so they stay off unless you add them yourself.

Do not copy the CMS example environment file

The CMS development example, apps/cms/.env.example, has DOKPLOY_ALLOW_INSECURE_HTTP=true and ALLOW_PRIVATE_HEALTH_CHECKS=true uncommented. Those values are for local development only. Use docker/.env.example for Docker deployments, and do not copy the CMS file into production.

Protect the secrets and back up the instance

Keep docker/.env readable only by the account that runs Docker, and never commit it. Store a copy in a secrets manager or another encrypted location. The secrets page lists every secret and where it lives.

The CMS refuses to start in production in three cases. REDIS_URL is not set. BETTER_AUTH_SECRET is an example value: change-me, or a value starting with replace- or replace_. An encryption key is all zero bytes. Generate real values with openssl rand -hex 32 for BETTER_AUTH_SECRET and openssl rand -base64 32 for each encryption key. The error names the setting that failed. See configuration.

Keep these values stable, because the data depends on them:

  • DOKPLOY_CREDENTIALS_ENCRYPTION_KEY decrypts the Dokploy API keys stored in the database. Changing or losing it means every saved key must be re-entered.
  • BETTER_AUTH_SECRET signs sign-in sessions. Changing it signs everyone out and can break two-factor sign-in for existing admins.
  • AI_SETTINGS_ENCRYPTION_KEY, if you set it, decrypts the saved OpenRouter key.
  • SECRETS_ENCRYPTION_KEY, if you set it, decrypts the SMTP password saved in Agency › Mail. Without it, the CMS falls back to AI_SETTINGS_ENCRYPTION_KEY.

What a backup contains

A complete backup has four parts: the PostgreSQL database, the S3 bucket with the uploaded files, the environment file with its encryption keys, and the image versions. A database dump alone is not enough. It does not include uploaded files, and saved Dokploy credentials cannot be decrypted without the encryption key. The backup and recovery page lists each part.

The repository includes a backup helper that captures all four. It needs Node on the host and runs from the repository root. The project name areca-demo comes from the name: field at the top of compose.yml:

node docker/recovery.mjs backup areca-demo docker/.env /secure/backups/areca-2026-10-01

The helper stops every service in the project while it copies the volumes, so plan a short outage. When it finishes, start the stack again with your usual docker compose up -d --wait command. The backup folder contains your secrets in plain form, so move it to encrypted storage that is not on the CMS host.

The backup and recovery page sets a recommended schedule: a daily database dump with the bucket copy, a full backup before every upgrade, and a quarterly restore drill. It also covers the restore steps. Test a restore into a separate project before you rely on it.

A backup is only useful if it restores

Run a restore into a new project with empty volumes at least once, and check that you can sign in, open a draft and see an uploaded file. Do this before you need it.

Watch the health endpoints

The CMS exposes three endpoints you can monitor:

EndpointUse
GET /api/healthLiveness. Returns 200 while the process is running. The image's built-in health check uses it.
GET /api/health/readyReadiness. Returns 503 when the database, storage or background queue is failing. A degraded instance, such as one with Redis down, a stale background loop or mail waiting more than 15 minutes, still returns 200. The response's degraded flag shows it.
GET /api/metricsPrometheus metrics. Requires Authorization: Bearer <METRICS_TOKEN>. Returns 404 when METRICS_TOKEN is not set.

Set METRICS_TOKEN in .env to enable metrics. The Compose file already passes it. See operations for the metrics list and example alerts.

Pin the image version

Set CMS_IMAGE in .env to a version tag or a digest, not latest:

CMS_IMAGE=ghcr.io/seirai-dev/areca-cms:v0.1.0

A pinned image means a restart never installs a new release by accident. Record the tag before and after each upgrade, because it is your rollback target. See upgrades.

On this page