Skip to content
V
For developers
Architecture, conventions, the core and security
Getting started / Deployment checklist

Deployment#

Scheduled tasks#

Without them the system does not work properly. Tasks are presenters in app/UI/Cron/, triggered by an HTTP request with a token.

Group What it does How often
E-mail queue sends every few minutes
Expiry warnings and deletion of expired adverts and listings daily
Recalculations category counts, prices 10–15 min
Saved searches sends alerts continuously or daily
Payments follow-up invoicing, evaluation continuously
Invoice scheduler recurring invoicing daily
Elasticsearch sync and a nightly reindex 10 min / daily
Sitemaps generation, including the e-shop ones and the index daily at night, the index last
Notification cleanup /cron/system/notification/clean-orphans — detaches notifications whose target record no longer exists (they stay as text without a link; otherwise they would lead to a 404 in the administration); each run checks the next 500 older notifications (the position is kept in the cache; after a cache flush it starts from the newest); runs itself at most once per 20 h daily
Notification retention /cron/system/notification/purge-read — deletes read notifications older than notification.readRetentionDays (default 90 days), never unread ones daily
Auth log /cron/system/auth-log/purge-old — deletes old login records (default 90 days) daily
E-mail queue retention /cron/system/email-queue/purge-old — deletes processed e-mails older than email.queue.retentionDays (default 30 days, Settings → E-mail); message bodies must not stay in the database forever daily
Uploader temporary files /cron/system/dropzone-temp/clean — removes files and folders older than 24 h from temp/dropzone (unfinished uploads) daily
Account deletion /cron/system/user/anonymize-deleted — anonymises accounts whose grace period after a deletion request has passed (account.deletion.graceDays, default 7 days); without it requests just wait daily
Logs /cron/system/log/purge-old — deletes Tracy error dumps older than log.retentionDays (default 30) and rotates *.log above log.maxSizeMb (default 50) to .log.1 (3 generations); Settings → Cron daily

What to schedule before going live

Besides the module jobs (e-mail queue, expiration, recounts, Elasticsearch, sitemaps), always schedule these five housekeeping jobs under /cron/system/…: notification/clean-orphans, auth-log/purge-old, email-queue/purge-old, dropzone-temp/clean, log/purge-old, plus user/anonymize-deleted (account deletion). Each is called with ?token=<cron.token>. notification/clean-orphans, dropzone-temp/clean, log/purge-old and user/anonymize-deleted guard themselves against running more than once per 20 h; auth-log/purge-old and email-queue/purge-old do not, so schedule them only daily.

Without scheduled tasks the problems surface only after weeks

Adverts never expire, counts stop matching, emails pile up. Nothing reports it. After deploying, check the tasks actually run.

Nothing ever executes in www/uploads/

Uploaded files live in the public tree and keep their extension. The application rejects dangerous extensions (php, phtml, phar, cgi, sh, .htaccess …) in any part of the name, but the web server is the second line of defence.

Apache reads the versioned www/uploads/.htaccess (PHP engine off, script extensions denied). It needs AllowOverride All (or at least FileInfo Options Limit) for the site directory.

nginx ignores .htaccess. Add this to the server block before the generic location ~ \.php$:

location ~* ^/uploads/.*\.(php[0-9]?|phtml|phar|pht|phps|cgi|pl|py|sh)(\.|$) {
    deny all;
}

After deployment verify that an uploaded test.php.txt in uploads/ is served as text, not executed.

Concurrency is handled by a lock

Two instances of the same task will not run. But do not schedule them needlessly often — a long task blocks the next one.

Deploying code#

Code reaches the server in a single step: the script deploy/deploy-demo.sh, run by hand from your own computer against your own server. A push to main deploys nothing by itself (the workflow .github/workflows/deploy-demo.yml is still in the repository, but it does not run). The script takes exactly the content of the commit (git archive HEAD), installs composer dependencies without dev packages, mirrors the code to the server with rsync over SSH and clears the cache in the same SSH session right away.

deploy/deploy-demo.sh               # deploys the current commit of the main branch
deploy/deploy-demo.sh --dry-run     # only shows what rsync would upload and delete
deploy/deploy-demo.sh --allow-dirty # allows uncommitted changes (still only the commit is deployed)
deploy/deploy-demo.sh --skip-tests  # emergency only: deploys a commit without green tests

Access to your server (user, path, key) comes from deploy/deploy-demo.local.env, which is not in git (template deploy/deploy-demo.local.env.example); replace the pinned server key (KNOWN_HOSTS_LINE) in the script with your own server's key, and likewise the site address the smoke test runs against. The script refuses to run outside the main branch and with uncommitted changes.

Only a commit that passed the tests is deployed: after a green run on a clean tree, tests/run.sh T1 (or a higher level) writes <commit> <level> to tests/.last-run, and without an entry for the current HEAD the script stops before uploading anything. The test levels are described in Tests and tools.

Step Implementation
swapping files rsync -rlptz --delete --delay-updates — files are uploaded aside and renamed only at the end, so the code swap takes a fraction of a second instead of the whole transfer
what is never overwritten or deleted deploy/rsync-exclude.txt: config/local.neon, config/Shared/local.neon, config/debug-mode.flag, uploads, uploads_thumbs, feeds, sitemaps, private/pdf, private/eshop, temp, log, var
the cache in temp/ only files are deleted (find temp -mindepth 1 -type f -delete), directories stay
SQL migrations bin/migrate.php after clearing temp/ and before clearing Redis; see SQL migrations
Redis the keys app:cache:q:* (query cache) and app:cache:route:* (the route table and slug maps) in database DEMO_REDIS_DB (default 0, see Several installations on one server); sessions and tag versions are not deleted
the server key pinned in the script (KNOWN_HOSTS_LINE, StrictHostKeyChecking=yes); ssh-keyscan is not used, so a spoofed server never receives the deploy key. If the key does not match, ssh/rsync stops before sending anything
smoke test after deployment the script runs php tests/Manual/Verify/demo_smoke.php <site-url> (read-only, no sign-in, no writes): the home pages of all languages, the listing of every module and one detail from it, the administration sign-in page, robots.txt and the sitemap it points to. A failure = a status other than 200 (after redirects), an error page or Tracy; slow pages are only reported. A missing sitemap is only a warning (a cron job generates it)
result when the smoke test fails, the code is already deployed, but the script ends with exit code 4 and reports the deployment as failed; a failed migration returns the migration's code
the first request takes about 9 s (a cold cache), the next 0.4–1.1 s; the smoke test also warms the site up, so the script pays for it, not a visitor

The first requests after clearing the cache can briefly fail

The smoke test runs right after the cache is cleared, while the DI container is still compiling, and the first requests can briefly return 500 or 503. A single failure therefore does not yet mean a failed deployment: run the smoke test again (php tests/Manual/Verify/demo_smoke.php <site-url>). Look for the cause in log/ only when the error persists.

Delete Redis keys always with xargs -r -d '\n'

Keys carry class names with backslashes (app:cache:q:App\UI\…) and the default xargs drops them from the arguments. The earlier redis-cli --scan … | xargs redis-cli del therefore did not find the key and deleted 12 of 4,457; the cache merely expired after the TTL. Correct:

redis-cli --scan --pattern 'app:cache:q:*' | xargs -r -d '\n' -n 200 redis-cli del

After deleting verify the count (only fresh keys should remain after deployment). The same applies to every manual deletion.

Uploading and clearing the cache must be ONE step

These used to be two steps and between them the new code ran with an old compiled DI container: every newly injected dependency threw Typed property … must not be accessed before initialization and RobotLoader: Unable to create …. It lasts seconds and fixes itself, but it is visible on the pages. A deleted directory under a running request threw a RobotLoader error, hence only files are deleted.

Deployment runs the SQL migrations itself

After uploading the files and clearing temp/, the script runs bin/migrate.php; if a migration fails, deployment ends with an error. For the procedure, the rules and the first run see SQL migrations.

The server local.neon keeps its own enabledServices

A new cached service must be added there too, otherwise it is read from the database (one extra query per request). See Cache.

Log rotation#

Without rotation the log/ directory grows and nobody reports it; debug.log and auth.log grow the most. The sources that wrote a line on every request were removed (route whitelists on every router build, INTERNAL_AUTH no_token on every internal read, "Dynamic routes" and "Lazy warmup triggered" in the translators); RBAC decisions, bad tokens, errors and exceptions are still logged.

Set up logrotate

Daily, 3 compressed generations, earlier at 200 MB; exception dumps *--*.html|md older than 14 days are deleted. Without rotation the log grows until the disk is full.

SQL migrations#

The schema is maintained by hand-written repeatable scripts in sql/. On deployment bin/migrate.php runs them on the database — nothing is run by hand any more and nobody has to remember the order.

What deployment does#

After uploading the files and clearing temp/, the SSH step runs bin/migrate.php (PHP 8.5, else 8.4, else the default php) and only then clears the Redis cache.

  1. It acquires the database lock coolcms_sql_migrations (GET_LOCK, waits 30 s) — only under it does it read the record, so two concurrent runs never run the same file twice.
  2. It reads which files have already run on this database (the table cms_system_sql_migrations: filename, checksum = SHA-256 of the content, applied_at).
  3. It verifies the checksums of the already applied files. If any file's content differs from the record, it prints its name and exits with code 1 — it runs nothing.
  4. It picks the pending migrations — files sql/YYYY-MM-DD_NN_description.sql that are not in the record — and runs them one by one, in file-name order.
  5. After each successful file it writes it to the record. A file that is in the record but missing from the folder is only reported with a warning.

The selection is done by App\Core\Utils\Helpers\SqlMigrationPlanner:

File Is it run?
YYYY-MM-DD_NN_description.sql yes, once
*_rollback.sql never — a rollback is run by hand
*_dev_pii_anonymization* never — development scripts
anything else in sql/ (another name shape) no

The code is uploaded BEFORE the migrations

The order of steps is: upload files → clear temp/ → migrations → clear the Redis cache. Between uploading the code and the end of the migration the new code therefore runs briefly over the old schema. A migration that adds a column the new code reads (formerly called "before the code") causes errors on the affected pages for that time. For a zero window, split the change into two deployments: first a migration the old code does not notice, and in the next deployment the code that uses it.

The first run and the baseline#

On a database where the record does not exist yet it is impossible to find out what was run by hand earlier. Without the record bin/migrate.php therefore exits with code 1 and only prints what it would take as done — it marks nothing silently. The first run is started by hand with --baseline:

php bin/migrate.php --baseline

It creates the table and every migration whose name sorts before 2026-10-02_08 (the constant SqlMigrationPlanner::BASELINE) is only recorded as done — not run. From 2026-10-02_08 on, everything is run. On a database where the record already exists, --baseline is not used.

A new server: the first run not from the deploy script

Deployment does not pass --baseline. On a new server without the record the migration step would end with an error — run the first run there by hand and only then deploy.

The database must be brought up to date by hand before the first run

What lies before the baseline will never run on its own. A database that came from an older dump must therefore have all older migrations applied by hand before deployment runs for the first time. This includes 2026-10-02_06_remove_google_settings.sql — it falls under the baseline, so on a database where it has not run yet, run it by hand after deploying the code.

Procedure: see what deployment will run#

php bin/migrate.php --dry-run

It lists the pending migrations in run order (or "no pending migration"). It writes nothing to the database, not even the record; on a database without the record it says how many older files would be taken as done on the first run. Without the switch the migrations are run; the command can also be run by hand (for example after fixing a failed migration). It reads the connection from parameters.mariadb in the configuration (config/Shared/parameters.neon, overridden by config/Shared/local.neon and config/local.neon).

What happens on an error#

Situation Behaviour
a migration fails the script prints the file and the error, exits with code 1, runs no further migration and does not record the failed file; deployment ends with an error
a migration fails in the middle of a file structure changes (DDL) are not rolled back; the file is run again from the start on the next deployment
the file ran but writing the record failed the script ends with an error; the file runs again next time
migrations are being run elsewhere at the same time the script waits 30 s for the lock coolcms_sql_migrations, then ends with an error
the content of an already applied migration changed (the checksum does not match) the script prints the files, exits with code 1 and runs nothing; deployment ends with an error
the record does not exist on the database without --baseline it exits with code 1, see The first run
PHP is not available on the server the migrations are not run and deployment ends with an error

After every run that executed at least one migration the script itself deletes the query cache and the route cache in Redis (<prefix>cache:q:*, <prefix>cache:route:*, default prefix app:; host, port and prefix are read from the configuration). Migrations change data outside saves through the managers, so no tag is invalidated and services with a longer cache lifetime (see Lifetime per service) would keep old content for up to an hour. Sessions and tag versions are not deleted. Without Redis (or with storage switched off) the script only prints that the cache will expire by itself. A run with no pending migration does not clear the cache.

A failed migration does not prevent the Redis cache from being cleared; only the whole step ends with an error so that somebody notices. After the fix, deploy again (or run php bin/migrate.php).

Rules for writing a migration#

Rule Why
the name YYYY-MM-DD_NN_description.sql, a rollback YYYY-MM-DD_<higher NN>_description_rollback.sql rollbacks have prefixes from 99 downwards and the runner skips them
a migration is repeatable (IF NOT EXISTS, INSERT IGNORE, NOT EXISTS) a failed file is run again from the start; DDL commits implicitly in MariaDB, a transaction guards nothing
no DELIMITER statement the file is sent to the server whole (multi_query); DELIMITER is a client command and does not work there
the prefix YYYY-MM-DD_NN is unique the order follows the whole file name; two scripts with the same prefix are ordered by the rest of the name, which is chance
do not edit an already deployed file the runner compares the checksum and on a mismatch stops the whole deployment. The fix is a new migration; a change with no effect on the database (a comment) is accepted deliberately with php bin/migrate.php --accept-changed — it only rewrites the checksum in the record and runs nothing again
files with personal data do not belong in sql/ rollbacks with original data (*_dev_pii_anonymization*) are outside the git index and in .gitignore; the runner never runs them either

tests/Unit/Sql/SqlMigrationPrefixTest.php fails on a new prefix collision (historical collisions are in the ALLOWLIST) and tests/Unit/Sql/SqlMigrationPlannerTest.php guards the selection, the order, the baseline and the reporting of changed and missing files.

Only the number expresses a dependency between migrations

The order is driven by the name alone. If migration B has to run after migration A, give it a higher NN — nothing else expresses the dependency.

Migrations of 1–2 October 2026#

In the order the runner runs them (by name). _01 to _07 fall under the baseline — on a freshly created record they are not run, only recorded.

File Note
2026-10-01_01_blog_article_news_region.sql a region on articles and news
2026-10-02_01 … _05 external connections: the table, Api privileges, the admin page, recaptcha_v3, four Google types
2026-10-02_06_remove_google_settings.sql deletes the old settings rows; after the code, by hand (see above)
2026-10-02_07_admin_discussion_message_reports.sql the admin page of reported posts
2026-10-02_08_response_mark_read_pages.sql pages 2011 and 2012, routes — the first one the runner runs by itself
…_09_response_mark_read_privilege.sql the markReadByKey keys
…_10_response_mark_read_email_button.sql the button in the email templates
…_11_response_read_key_column.sql the read_key column on responses
…_12_response_receiver_role.sql the role receiver; replaces the grant from _09
…_13_rating_save_reply_receiver.sql Rating:saveReply from owner to receiver
…_14_message_thread_owner_receiver.sql roles over message threads
…_15_message_read_receipts_setting.sql the read-receipts switch
…_16_message_thread_participants_admin.sql managing thread participants in the administration
…_17_demo_admin_password_repair.sql restores the administrator password on the public demo; runs only on its database and only when the password is empty
…_18_admin_roles_page_href_lowercase.sql lower-case addresses of roles, permissions and groups

Find the rollback of a migration by the description in its name: …_<higher number>_description_rollback.sql (for this batch 2026-10-01_91 and 2026-10-02_82 to _99).

After applying by hand, clear temp/cache and flush Redis

Deployment clears the cache itself. When you run a migration by hand (a rollback, a fix), RBAC, the router, the pretty-URL map and Doctrine metadata stay cached. Scripts that add pages or routes do not invalidate the route-table cache (the key app:cache:route:table) — without clearing, the new route is not loaded. For deleting keys see above.

Server operating requirements#

What Requirement Why
PHP-FPM memory_limit at least 256M; for the full 40 Mpx image cap about 320M compiling the DI container after deployment peaks at ~128 MB in a web request — with 128M the first requests failed with 500. An image is checked by a memory estimate against the free part of the limit; with 128M only images up to ~12–15 Mpx pass
CLI memory_limit higher than FPM, or -1 compiling in CLI (bin/migrate.php, a different container) needs ~200 MB; a bin/*.php crash at 128M is not an application bug
ffmpeg / ffprobe installed video is verified by content before it is stored; without ffprobe video is rejected (logged to log/assets.log), images keep working
Redis Restart=always in systemd and monitoring an outage no longer brings the site down — see below — but the query cache and ES run slower without it
logrotate a second safeguard next to the log/purge-old cron see Log rotation

Sessions survive a Redis outage

When Redis fails while reading a session, TieredTtlSessionHandler switches that request's sessions to files in temp/sessions (the sessionTtl.fallbackDir option) and the next request tries Redis again. One line per hour goes to warning.log, and a line to info.log when Redis is back. Sign-ins made during the outage are lost once Redis returns. See Cache.

The webroot is not derived

App\Bootstrap sets %wwwDir% explicitly to <root>/www. Before, nette/bootstrap derived it from the outermost stack frame, so a script started from elsewhere (a harness, a runner) built paths one level off and wrote into www/www/. www/.htaccess returns 403 for an existing file under www/www/.

Several installations on one server#

One server can run several CoolCMS installations, for example a live site and its public demo. Each installation has its own database, files, temp/ and logs. Redis, Elasticsearch and browser cookies are shared, though, until they are separated in local.neon. Redis keys carry no installation id: jwt:denylist:…, ratelimit:…, app:cache:…, Contributte.Storage:… and sessions under the bare session ID are the same for every installation.

Check local.neon before deploying another installation onto the same server

With the same configuration the installations overwrite each other's cache and sign each other's users out. They invalidate each other's API tokens, add up their request limits and write into the same search indexes: the document with id 5 of one installation overwrites the document with id 5 of the other. None of this is reported; it only shows as odd behaviour.

Setting in parameters Default What it separates When to change it
redis.database 0 the whole Redis content: cache, sessions, JWT deny-list, request limits, Elasticsearch state every installation with Redis on gets a different number (Redis has 16 by default, 0–15)
elasticsearch.indexPrefix cms indexes and aliases <prefix>_<source>, keys es:<prefix>:… in Redis every installation with Elasticsearch on; lowercase letters and digits only
sessionName coolsoftcms the session cookie name installations on the same second-level domain (company.com and demo.company.com), even without Redis
sessionCookieDomain domain domain sends the session cookie to the whole second-level domain including subdomains, null only to the current host null is enough for the second and further installation on the same domain

A second installation on the same server and domain has, for example, this in config/Shared/local.neon:

parameters:
    redis:
        database: 1
    elasticsearch:
        indexPrefix: 'company'
    sessionName: 'companyweb'
    sessionCookieDomain: null

The first installation can keep the defaults. Changing redis.database or indexPrefix on an installation that is already running means an empty cache, all users signed out and a new reindex of all Elasticsearch sources.

Do not use the Predis prefix option

Predis can add a prefix to every key (redis.connection.default.options.prefix), but it does not add it to the SCAN pattern. Clearing the own cache would then find nothing, and deleting by KEYS would add the prefix twice. Installations are separated only by the database number.

Installations without Redis and Elasticsearch#

A smaller site needs neither Redis nor Elasticsearch. With this configuration the installation does not connect to them at all, so it does not affect the shared Redis and cluster of the neighbouring installations:

parameters:
    redis:
        storage: false        # Nette cache in files under temp/
        sessions: false       # sessions as ordinary PHP sessions
    jwt:
        useRedis: false       # token deny-list in the database; request limits follow it
    elasticsearch:
        enabled: false
    cache:
        config:
            enabled: false
            routerEnabled: false

cache.config.enabled and routerEnabled are off by default and are listed to be sure. With both off, neither tag versions nor cache statistics are written into Redis. sessionName is needed here too, because it is a browser cookie, not Redis.

Sessions in files: every site its own directory

Without Redis, PHP stores sessions in session.save_path. If two sites share one directory, the clean-up of old sessions of the site with the shorter lifetime deletes the other site's sessions too. Check that every PHP-FPM pool has its own php_admin_value[session.save_path]:

grep -H "session.save_path" /etc/php/*/fpm/pool.d/*.conf

Deployment and clearing the cache by hand#

Where How it knows the database number
deploy/deploy-demo.sh the DEMO_REDIS_DB variable in deploy/deploy-demo.local.env (default 0); deletes with redis-cli -n <number>
bin/migrate.php reads redis.database from the configuration by itself
clearing by hand always with -n <number>: without it redis-cli works on database 0, i.e. another installation
redis-cli -n 1 --scan --pattern 'app:cache:q:*' | xargs -r -d '\n' -n 200 redis-cli -n 1 del

Each installation sets up its scheduled tasks separately, with its own token. This includes the Elasticsearch sync and nightly reindex.

Shared server capacity#

What Why to watch it
OPcache installations on the same PHP-FPM version share one OPcache. One installation has about 15–20 thousand PHP files including dependencies, plus compiled templates and the container. For two installations raise opcache.max_accelerated_files to at least 65407 and opcache.memory_consumption accordingly, otherwise OPcache keeps filling up and resetting
Redis memory maxmemory applies to the whole Redis. Use the noeviction policy: allkeys-lru would also evict sessions and the token deny-list when full
Elasticsearch heap the indexes of all installations live in one cluster and one heap
PHP-FPM every site has its own pool and its own pm.max_children; the sum of worker processes must fit into the server memory

Checking the separation#

redis-cli INFO keyspace                           # every installation has its own db<number> line
curl -s -u elastic 'http://127.0.0.1:9200/_cat/aliases?v'   # aliases start with the installation prefix

In the browser, sign in to both installations and switch between them. Neither may sign the user out, and each must have a session cookie with its own name.

PHP 8.5#

The code is verified on PHP 8.4 and 8.5: php8.5 -l over the whole of app/ (5,821 files) without errors, composer why-not php 8.5.0 without an obstacle, and after the fix below a click-through of the front and the administration passed. Unit tests have not yet been run under 8.5 (the local machine runs 8.4).

Redeclaring an injected property in a trait takes down all pages of the presenter

AddAdvertFormTrait (Bazaar, Comcat) declared public CurrencyManager $currencyManager; without #[Inject], while BasePresenter inherits the same property with #[Inject]. PHP 8.5 returns the descendant's declaration (without the attribute) on reflection, so Nette did not fill it and every page of AdvertPresenter fell on must not be accessed before initialization. On 8.4 it worked. The fix: remove the declaration from the trait. A scan of the whole of app/ found no other case.

The rule: a trait or a descendant must not redeclare a property the ancestor injects. If you need it, use the inherited one.

When you suspect “PHP 8.5” first rule out a stale container

The same message also arises on deployment with an old DI container (see above). They are told apart by it disappearing after temp/ is cleared; a genuine redeclaration stays.

Production settings#

Item Setting
Debug mode off
Error display off, log instead
Cache on
docs/sql/ not reachable from outside
Gateway credentials in local.neon only
www/uploads/ no script execution

The debug panel prints the configuration, credentials included

Left on in production, the keys to payment gateways and other services end up in the page source. Verify it is off.

How debug mode is decided#

The production default is off; it is switched on explicitly. App\Bootstrap::resolveDebugMode() decides in this order:

  1. the NETTE_DEBUG environment variable — 1, true, on, yes turn it on, anything else including 0 and false turns it off,
  2. the existence of the config/debug-mode.flag file (not in the repo; on the dev server you create it with touch),
  3. otherwise off.

The environment variable overrides the flag file

When NETTE_DEBUG is set to anything outside the permitted four values, the flag file is not read at all. On a machine where the variable is set by accident, touch will therefore not help.

Building assets#

npm run build:admin:css
npm run build:admin:js

Use the global sass, not npx sass

Different versions producing different output. The difference shows as small layout shifts nobody connects with the deployment.

Deploying onto an existing database#

Upgrading an older installation inherits more than data — it inherits schema defects nobody has seen for years. Run the Database check before deploying; it is read-only SELECTs and safe against production.

The first query matters most: it lists *_texts translation tables missing their composite primary key. Without it the database accepts duplicate translations, and every further seed run adds more.

Automated checks will not find this

SchemaValidator::validateClass compares columns and types, not indexes — a missing primary key passes it. The only ways to find out are this query, or the site showing different text than the admin does.

The chapter covers what to do about a finding, with the fix scripts to copy.

Post-deployment checks#

The smoke test in the script only verifies that pages render. After deployment also go through:

  1. The home page and signing in.
  2. One administration listing and one form.
  3. A test order all the way to dispatch.
  4. The email queue — is it sending?
  5. The error log — anything new?
  6. Database check — when deploying onto an existing database, run it again to verify the migrations did not introduce duplicate translations.
  7. If another installation runs on the server, check the separation: its own Redis database, index prefix and session cookie name.

A test order is the best check

It goes through the cart, payment, stock, invoicing and email at once. One pass tests more than an hour of clicking around screens.

Cookie settings