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.
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.
/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.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.
files, delete it. create-project insists on an empty target.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.
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.
Out of the box Concrete serves every page as /index.php/path.
./vendor/bin/concrete c5:config set concrete.seo.url_rewriting trueOn 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.
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:
crontab -e#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.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:
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.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.
The server can send mail, and the internet will mark it as spam. Point Concrete at a transactional provider (Postmark, SES, Mailgun) under System & Settings → Email, using SMTP credentials from the provider. Do it now; the first symptom of unsent mail is a user who never got their password reset.
Concrete has its own page cache with its own invalidation, under System & Settings → Optimization, 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.
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.
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.