Skip to main content

Self-hosting Concrete CMS

Concrete CMS 9 · Updated 2026-09-04

SpinupWP is a WordPress-first control panel, but underneath the branding it provisions the stack Concrete wants: Nginx, PHP-FPM and MySQL 8 on an Ubuntu server in your own cloud account. This guide takes a blank server to a live Concrete CMS 9 site, with cron, mail and backups set up.

Before you start

SpinupWP gives you provisioning, per-site Linux users, Let's Encrypt certificates, git push-to-deploy and scheduled backups, which are the operational chores that otherwise mean writing your own runbooks. You pay for SpinupWP plus whatever your cloud provider charges for the server, and the WordPress-specific features sit unused.

You need a SpinupWP account, an account with a cloud provider it supports (DigitalOcean, Linode, Vultr, AWS, or any Ubuntu server you can SSH into), and a domain you control.

Provision the server

  1. In SpinupWP, create a server on your cloud provider account. Concrete 9 is comfortable on 1 GB of RAM for a small site; 2 GB gives Composer room during installs and upgrades.
  2. Choose the newest PHP version offered. Concrete 9.5 supports PHP 8.5, and older 9.x releases run on 8.2 and up.

Create the site

  1. Add a new site and choose Blank site, not a WordPress install.
  2. Set the domain, and let SpinupWP create the site user. Each site runs as its own Linux user, which is the isolation doing the security work.
  3. Set the Public Folder to /public. SpinupWP serves sites from /sites/<domain>/files, and a Composer-managed Concrete install keeps its webroot in public/, so the served path becomes /sites/<domain>/files/public.

Create the database

On the server's Databases screen, add a database and a user for the site, and note the name, user and password for the installer. Concrete wants MySQL 5.7 or later, or MariaDB; SpinupWP's MySQL 8 is fine.

Install Concrete

  1. SSH in as the site user. SpinupWP shows the connection details on the site's overview.
  2. If SpinupWP dropped a placeholder index file in files, delete it. create-project insists on an empty target.
  3. Install into the site's files directory and run the installer:
    cd /sites/<domain>/files
    composer create-project -n concretecms/composer .
    ./vendor/bin/concrete c5:install -i
    The interactive installer asks for the database credentials, your admin account, and a starting point.

Prefer the CLI installer over the browser one here. It runs as the site user, so file ownership comes out right without any chown-ing afterwards.

Two things that cost us time on a real install. The skeleton ships a .env.dist; do not create .env from it until the installer has finished, because CONCRETE5_ENV="live" switches on live.database.php and Concrete then treats the empty database as an installed site. And if you script the installer with --no-interaction instead of -i, the canonical URL needs its trailing slash: https://example.com/, not https://example.com.

Point DNS and enable HTTPS

  1. Point the domain's A record at the server and wait for it to resolve.
  2. In SpinupWP, enable HTTPS on the site. It issues and renews the Let's Encrypt certificate and redirects HTTP for you.

Enabling HTTPS before the DNS change fails certificate validation, which is the usual reason the toggle appears not to work. Clicking it again while the first attempt is still waiting gets you "Another instance of Certbot is already running"; let the first attempt finish, then retry once the A record resolves.

Turn on pretty URLs

Out of the box Concrete serves every page as /index.php/path.

  1. Turn on pretty URLs under System & SettingsSEO & Statistics, or from the CLI:
    ./vendor/bin/concrete c5:config set concrete.seo.url_rewriting true
  2. Test a few interior pages.

On a SpinupWP blank site this works as it is: the generated Nginx config already sends unmatched paths through index.php, so there is nothing to add. On a server you configured yourself, a 404 on interior pages means the server block needs the standard rewrite:

location / {
    try_files $uri $uri/ /index.php$is_args$args;
}

If you ever do need to change Nginx on SpinupWP, it supports per-site custom config files; see their "Changing Nginx Settings" doc for where they live on your server generation. Do not edit the generated site config directly, because it can be rewritten when site settings change.

Give the scheduler real cron

Concrete 9's automated tasks run through a scheduler that expects to be run every minute, and there is no web-request fallback like WordPress's pseudo-cron. Without a cron entry, scheduled tasks never run.

The site's Cron tab in SpinupWP is not the place for it. That toggle runs WordPress's wp-cron.php at an interval you set in minutes, and it cannot run a different command; switch it off for a Concrete site, or it keeps starting a WordPress command that has nothing to run. Add Concrete's entry to the site user's crontab instead, as SpinupWP's cron doc describes:

  1. SSH in as the site user and open its crontab:
    crontab -e
  2. Add this line and save. If there is a line marked #Ansible: <domain>, leave it alone; SpinupWP manages that one and rewrites it.
    * * * * * php /sites/<domain>/files/vendor/bin/concrete concrete:scheduler:run >> /sites/<domain>/logs/scheduler.log 2>&1
    The log redirection is so a failing task leaves a trace.
  3. Schedule the tasks you want under System & SettingsAutomationTasks. The scheduler only runs tasks that have a schedule set there; the Activity tab shows its runs.

If the site is behind Cloudflare

Concrete drops a session when the visitor's IP address changes between requests, and behind Cloudflare's proxy every request reaches the server from a different Cloudflare edge address. The symptom is specific: you can log in, but the first save bounces you to the login page, and logging in again fails with "Invalid form token". Fix it by restoring the real visitor address before Concrete sees it. Any one of these does it:

  1. Install our free Cloudflare Real IP add-on. It fills in Concrete's own Trusted Proxies setting with Cloudflare's ranges, keeps them current with a scheduled task, and its Dashboard page tells you whether the address is being restored. No root needed, nothing to configure.
  2. In Nginx, as a sudo user. SpinupWP's nginx.conf does not include conf.d, so the file goes in the site's own server directory, which SpinupWP includes inside the server block and leaves alone:
    sudo tee /etc/nginx/sites-available/<domain>/server/cloudflare-realip.conf > /dev/null <<'EOF'
    set_real_ip_from 173.245.48.0/20; set_real_ip_from 103.21.244.0/22; set_real_ip_from 103.22.200.0/22; set_real_ip_from 103.31.4.0/22;
    set_real_ip_from 141.101.64.0/18; set_real_ip_from 108.162.192.0/18; set_real_ip_from 190.93.240.0/20; set_real_ip_from 188.114.96.0/20;
    set_real_ip_from 197.234.240.0/22; set_real_ip_from 198.41.128.0/17; set_real_ip_from 162.158.0.0/15; set_real_ip_from 104.16.0.0/13;
    set_real_ip_from 104.24.0.0/14; set_real_ip_from 172.64.0.0/13; set_real_ip_from 131.0.72.0/22;
    set_real_ip_from 2400:cb00::/32; set_real_ip_from 2606:4700::/32; set_real_ip_from 2803:f800::/32; set_real_ip_from 2405:b500::/32;
    set_real_ip_from 2405:8100::/32; set_real_ip_from 2a06:98c0::/29; set_real_ip_from 2c0f:f248::/32;
    real_ip_header CF-Connecting-IP;
    EOF
    sudo nginx -t && sudo systemctl reload nginx
    The ranges are Cloudflare's published list as of September 2026; check cloudflare.com/ips when you copy them. Nginx only trusts the header for connections from those ranges, so nobody else can fake an address.
  3. In Concrete by hand, which is what the add-on does for you. Concrete's trusted-proxies setting reads the real address from Cloudflare's forwarding headers. In application/config/concrete.php:
    'security' => [
        'trusted_proxies' => [
            'ips' => ['173.245.48.0/20', '103.21.244.0/22', /* the rest of the list above */],
            'headers' => ['client_ip', 'client_proto'],
        ],
    ],

Confirm with the access log: after the change, tail /sites/<domain>/logs/access.log shows your own address instead of a 172.x or 104.x one.

Send mail through a provider

The server can send mail, and the internet will mark it as spam. Point Concrete at a transactional provider (Postmark, SES, Mailgun) under System & SettingsEmail, using SMTP credentials from the provider. Do it now; the first symptom of unsent mail is a user who never got their password reset.

Leave the SpinupWP page cache off

Concrete has its own page cache with its own invalidation, under System & SettingsOptimization, and it knows when you edit a page. A second cache in front of it does not, and stale-page bugs from stacked caches are miserable to diagnose. Use the cache the CMS controls.

Turn on backups

SpinupWP can back up the site's files and database on a schedule to S3-compatible storage. Turn it on, set a retention period, and do one restore drill into a scratch site before you rely on it.

Update Concrete later

On a Composer-managed install, core updates come through Composer, followed by the database migrations:

composer update concretecms/core --with-dependencies
./vendor/bin/concrete c5:update

Take a database backup first, every time. The official docs carry the upgrade notes for each release; read them before a major jump.