agentleFS
educlopezeduclopez/mallard/skills/ploi-prestashop-deploy/SKILL.md

ploi-prestashop-deploy

Skill4 starsChanged 14 days agoMITView on GitHub

Deploys a PrestaShop 8/9 project from a local Lando setup onto a fresh Ploi-managed server (site creation, file upload, DB migration, and the PrestaShop-specific nginx rules Ploi's generic template doesn't know about). Use when the user says "monta esto en el servidor Ploi", "sube el proyecto a Ploi", "despliega en el server nuevo", "crea el site en Ploi para X", "pasa esto a producción/pre en Ploi", or is setting up a brand-new domain on a Ploi server for a PrestaShop project. Also use when debugging a freshly-deployed PrestaShop site on Ploi that shows a 404 on admin login, broken product/category images, a memory_limit fatal in the backoffice, or a redirect to the old local .lndo.site domain — these are almost always the same handful of known Ploi+PrestaShop gaps this skill documents, not a new bug in the project itself.

  • Reads credentials
  • Deletes or force-pushes

The file

Show its rules and text

We split this skill into the rules it gives an agent

14 rules. About 0 are common, 8 are distinctive, and 6 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈8

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 6

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Ask the user (if not already given):

    line 57
  2. Distinctive:

    Test SSH connectivity before doing anything else:

    line 68
  3. Distinctive:

    Check PrestaShop's own compatibility table (search "PrestaShop PHP compatibility matrix") for the exact minor version — it changes per …

    line 75
  4. Distinctive:

    Check what's already installed on the server:

    line 80
  5. Distinctive:

    If the needed version isn't listed, install it from Ploi panel → Server → PHP → Install new version, before creating the site.

    line 84
  6. Distinctive:

    Ask the user which they want — rsync is faster to get a first working copy up when there's no CI/CD pipeline wired yet.

    line 101
  7. Very specific:

    Leave vendor/ in the sync — including it skips a composer install on a server that may not have the exact same Composer/extension setup as …

    line 129
  8. Very specific:

    Verify after: ssh ... "ls {webroot}/index.php" and check total size roughly matches local size minus the excluded dirs.

    line 132
  9. Very specific:

    Run via mysql -h127.0.0.1 -u{db_user} -p'{db_pass}' {db_name} -e "...".

    line 199
  10. Very specific:

    If the theme uses ElementFlow/stsitebuilder, its block content is JSON stored in ps_st_site_builder_postmeta.meta_value (key …

    line 224
  11. Very specific:

    Check both http:// and https:// variants.

    line 243
  12. Very specific:

    Do this on every fresh deploy where var/cache/ was excluded from the rsync (Step 4) or wiped — don't rely on the .user.ini bump alone to …

    line 326
  13. Distinctive:

    If the count didn't move, the request never reached PrestaShop's Symfony admin kernel at all — it's purely an nginx routing gap, full stop, …

    line 348
  14. Distinctive:

    When something on a fresh Ploi PrestaShop site behaves unexpectedly and the cause isn't obvious, compare against a known-working PrestaShop …

    line 526

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

ploi-prestashop-deploy

Trigger

New deploy (fresh domain/site not yet on the server): "monta esto en Ploi", "sube el proyecto a Ploi", "crea el site en Ploi para X", "despliega en el server nuevo" → Run all steps in order.

Debugging an existing Ploi+PrestaShop deploy (site already up, something's broken): 404 on /adminXXX/login, broken images, Allowed memory size ... exhausted, redirects to a .lndo.site domain, admin panel unreachable → Skip straight to Troubleshooting — match the symptom to its cause there before re-deriving it from scratch.

Why this skill exists

Ploi is a generic server-management panel — its default nginx template is written for a plain PHP script or a Laravel app, not PrestaShop. PrestaShop's admin panel and SEO-friendly image URLs depend on rewrite rules that Apache normally provides via .htaccess, which nginx does not read. Every symptom in Troubleshooting traces back to this one gap, dressed up differently each time (a 404 that renders as the storefront's own theme, a PHP memory fatal that looks unrelated, images that 404 despite the files existing on disk). Once the PrestaShop-aware nginx config (Step 10) is in place, all of it resolves at once.

Placeholders

  • {domain} — the site's final domain (e.g. shopname.pre-testing.com)
  • {ssh_key} — SSH key for the Ploi server (e.g. id_rsa_cinetic)
  • {server_ip} — the Ploi server's IP
  • {local_project_dir} — the local Lando project directory
  • {admin_dir} — the project's admin folder name (random hash like adminea8094707b02d4ca, or a custom name like baupanel — check with ls {local_project_dir} | grep -i admin)
  • {db_name} / {db_user} / {db_pass} — credentials Ploi generated for the site's database

Step 1 — Confirm inputs

Ask the user (if not already given):

  • Local project directory and its PrestaShop version (grep -m1 VERSION src/Core/Version.php or grep _PS_VERSION_ config/defines.inc.php)
  • Target domain
  • Server IP + SSH key
  • Does the user have Ploi panel access themselves, or should you drive it? If they have access, most of Steps 2 and 10 are "give the user the panel steps/content", not something you do directly — Ploi's nginx sites-available files and /etc/php/*/fpm/php.ini are root-owned; without a passwordless sudo on the SSH account (the common case), you cannot edit them yourself and must go through the panel.

Test SSH connectivity before doing anything else:

ssh -i ~/.ssh/{ssh_key} -o ConnectTimeout=10 -o BatchMode=yes ploi@{server_ip} "whoami && hostname"

Step 2 — Check PHP version compatibility BEFORE creating the site

Check PrestaShop's own compatibility table (search "PrestaShop PHP compatibility matrix") for the exact minor version — it changes per release:

  • PS 9.0 → PHP ≤ 8.4
  • PS 9.1 → PHP ≤ 8.5

Check what's already installed on the server:

ssh -i ~/.ssh/{ssh_key} ploi@{server_ip} "ls /etc/php/"

If the needed version isn't listed, install it from Ploi panel → Server → PHP → Install new version, before creating the site. Don't assume the server's default PHP version is right for this specific PrestaShop minor version — check the table every time, PHP releases move faster than PrestaShop's support window.

Step 3 — Create the site in the Ploi panel

Never mkdir the site folder over SSH. Ploi's own database tracks sites, SSL certs, PHP-FPM pool assignment, and deploy scripts — creating the folder by hand leaves all of that disconnected from the panel (no SSL renewal, no PHP version management, nothing).

Site → New site → domain = {domain}, PHP version = the one confirmed in Step 2. Confirms the convention: webroot ends up at /home/ploi/{domain}/public.

Step 4 — Upload the codebase

Two paths: git-based deploy (Ploi's own deploy key + deploy script) or a direct rsync copy. Ask the user which they want — rsync is faster to get a first working copy up when there's no CI/CD pipeline wired yet; git-based deploy is right when the project is meant to auto-deploy on push going forward. This skill covers rsync; if git-based, use Ploi's own "Repository" + "Deploy script" site config panels instead of the steps below.

cd {local_project_dir} && rsync -az --progress -e "ssh -i ~/.ssh/{ssh_key}" \
  --exclude='.git/' \
  --exclude='.github/' \
  --exclude='.githooks/' \
  --exclude='.claude/' \
  --exclude='.codegraph/' \
  --exclude='.cursor/' \
  --exclude='.atl/' \
  --exclude='.lando/' \
  --exclude='.ui-craft/' \
  --exclude='.docker/' \
  --exclude='node_modules/' \
  --exclude='var/cache/' \
  --exclude='var/logs/' \
  ./ ploi@{server_ip}:/home/ploi/{domain}/public/

Notes:

  • macOS ships an old rsync (2.6.9) — it doesn't understand --info=progress2, use --progress.
  • node_modules/ exclusion matches at any depth (no leading /), so it also catches nested ones (themes/*/node_modules, adminXXX/themes/*/node_modules — PS9 admin themes can have their own).
  • Leave vendor/ in the sync — including it skips a composer install on a server that may not have the exact same Composer/extension setup as local. It's usually 100-250MB, cheap compared to running composer resolution on a machine you don't fully control.
  • Verify after: ssh ... "ls {webroot}/index.php" and check total size roughly matches local size minus the excluded dirs.

Step 5 — Migrate the database

lando mysqldump does not exist — the command is lando db-export, and its target path must be inside the project directory (the only thing bind-mounted into the container); an absolute path like /tmp/foo.sql outside the project fails silently with "No such file or directory" from inside the container, not from your shell.

cd {local_project_dir} && lando db-export dump.sql

(Ignore a stray .gz-suffixed output name from Lando's exporter — check with file dump.sql* whether it actually gzipped; sometimes it names it .gz without compressing.)

Upload and import:

scp -i ~/.ssh/{ssh_key} dump.sql ploi@{server_ip}:/home/ploi/dump.sql
ssh -i ~/.ssh/{ssh_key} ploi@{server_ip} \
  "mysql -h127.0.0.1 -u{db_user} -p'{db_pass}' {db_name} < /home/ploi/dump.sql"

Use -h127.0.0.1 (TCP), not a bare mysql -u... -p... (socket). On Ploi's MySQL setup the per-site DB user is only granted for TCP/127.0.0.1, not the local socket — the exact same credentials that work with -h127.0.0.1 fail with a generic "Access denied" over the socket, which reads exactly like a wrong password. If you hit "Access denied" with credentials you're sure are correct, try -h127.0.0.1 before assuming the user made a typo.

Collation gotcha. Local Lando MySQL (8.0.x) or MariaDB dumps can use newer collations (utf8mb4_uca1400_ai_ci, utf8mb3_uca1400_ai_ci) the server's MySQL version doesn't recognize:

ERROR 1273 (HY000) at line N: Unknown collation: 'utf8mb4_uca1400_ai_ci'

Fix by patching the dump, not by downgrading anything:

ssh -i ~/.ssh/{ssh_key} ploi@{server_ip} "
  sed -i 's/utf8mb4_uca1400_ai_ci/utf8mb4_unicode_ci/g; s/utf8mb3_uca1400_ai_ci/utf8mb3_general_ci/g' /home/ploi/dump.sql
"

Re-run the import after patching. Check for both variants — a dump can carry only one and still fail again on the second a few hundred lines later if you only patch the first.

Clean up the dump file from both ends once the import is verified.

Step 6 — Point parameters.php at the real DB

ssh -i ~/.ssh/{ssh_key} ploi@{server_ip} "
sed -i \"s/'database_host' => '[^']*',/'database_host' => '127.0.0.1',/\" {webroot}/app/config/parameters.php
sed -i \"s/'database_name' => '[^']*',/'database_name' => '{db_name}',/\" {webroot}/app/config/parameters.php
sed -i \"s/'database_user' => '[^']*',/'database_user' => '{db_user}',/\" {webroot}/app/config/parameters.php
sed -i \"s/'database_password' => '[^']*',/'database_password' => '{db_pass}',/\" {webroot}/app/config/parameters.php
"

Step 7 — Fix the shop domain in the database

Without this, PrestaShop redirects every request back to the local .lndo.site domain baked into the imported DB — the site loads then immediately bounces to a domain that doesn't resolve outside your Mac.

Two separate places store the domain — both must be updated:

UPDATE ps_shop_url SET domain = '{domain}', domain_ssl = '{domain}' WHERE id_shop_url = 1;
UPDATE ps_configuration SET value = '{domain}' WHERE name IN ('PS_SHOP_DOMAIN', 'PS_SHOP_DOMAIN_SSL');

Run via mysql -h127.0.0.1 -u{db_user} -p'{db_pass}' {db_name} -e "...". Clear {webroot}/var/cache/* afterwards — PrestaShop caches resolved URLs.

Step 8 — Strip absolute local-domain URLs from page-builder content

Theme-specific — check which theme/builder the project actually uses before assuming this applies. Everything below (table names, column names, file paths) is exactly how ElementFlow's stsitebuilder module (an Elementor-based page builder) happens to store and export its content — that's the theme both reference projects (gaudi, llobet) used, not a general PrestaShop behavior. A different theme means different specifics:

  • Panda (SunnyToo) and its Easy Builder (steasybuilder) store layout differently — check its own DB tables/config before assuming ps_st_site_builder* exists at all.
  • Classic theme with no page builder (plain Smarty/Bootstrap, hooks-only customization) won't have this problem at all — a hardcoded local domain would only show up in actual CMS page content (ps_cms_lang.content) or a module's own config, both of which the broad text-column scan in Step 7's troubleshooting note already covers.

The general lesson to carry forward regardless of theme is: any visual/page builder that lets you paste absolute image or link URLs into a WYSIWYG editor can bake the local dev domain into stored content, and that content might live in a DB column, a builder-specific cache, or (Step 8b) a statically-exported file. Confirm which theme/builder a new project uses first (ls themes/, check composer.json/config.xml for stsitebuilder vs steasybuilder vs neither), then adapt the table/path names below — don't blindly run ElementFlow-specific SQL against a Panda or Classic-theme project.

If the theme uses ElementFlow/stsitebuilder, its block content is JSON stored in ps_st_site_builder_postmeta.meta_value (key _elementor_data), and it can contain absolute URLs baked in at edit time (https://project.lndo.site/img/cms/...). These don't get fixed by Step 7 — they're literal strings inside the JSON blob, not looked up via the shop domain config.

Find them:

SELECT meta_id, post_id FROM ps_st_site_builder_postmeta WHERE meta_value LIKE '%lndo.site%';

Strip the absolute prefix so the URL becomes root-relative (works regardless of domain, which is what you actually want for content that might move between staging/prod later):

UPDATE ps_st_site_builder_postmeta
SET meta_value = REPLACE(REPLACE(meta_value,
  'https://{local-lando-domain}', ''),
  'http://{local-lando-domain}', '')
WHERE meta_value LIKE '%{local-lando-domain}%';

Check both http:// and https:// variants. This is theme/builder-specific — if the project uses a different page builder, check wherever it stores block content the same way (look for longtext/JSON columns with a name suggesting builder data).

If images/menu links still show the old domain after this AND after clearing var/cache/*, the content isn't in the DB at all — check for statically-exported template files next (Step 8b). Don't assume a DB fix that returns 0 remaining rows means the job is done; a grep against the live rendered page for the old domain is the only proof that actually matters, and DB + cache being clean while the page still shows it is itself the signal to keep looking rather than a contradiction to explain away.

Step 8b — Check for statically-exported builder templates

Also ElementFlow/stsitebuilder-specific (same caveat as Step 8) — the path modules/stsitebuilder/views/templates/front/template/ won't exist on a Panda or Classic-theme project. If a different builder is in play, look for whatever its equivalent "compiled/exported template" location is (check its module's views/templates/ tree for auto-generated .tpl files with builder-looking names, distinct from the theme's own hand-written templates) — the general pattern (a builder exporting rendered content to a real file instead of re-reading the DB every request) is common across Elementor-style builders even when the exact path differs.

stsitebuilder (and similar Elementor-style builders) can export a block's content to a real .tpl file on disk the first time it's saved/rendered — not a cache, not regenerated from the DB automatically, just a plain file checked in alongside the module, under something like modules/stsitebuilder/views/templates/front/template/*.tpl. If that export happened locally before the DB content was ever cleaned up, the exported file carries the old absolute domain baked in permanently, independent of the DB row it was exported from. Clearing var/cache/* (Symfony) or Smarty's own cache/smarty/{cache,compile}/* (different directory, PS9's dev-mode Smarty output lives at var/cache/dev/smarty/... and gets regenerated on every request in dev mode, so it always looks "fresh" without ever becoming correct) does nothing for these, because they aren't cache — they're source.

Find them and check both the absolute (https://olddomain/...) and protocol-relative (//olddomain/...) forms — builders commonly emit the latter for asset URLs:

ssh -i ~/.ssh/{ssh_key} ploi@{server_ip} "
  DIR={webroot}/modules/stsitebuilder/views/templates/front/template
  grep -rl '{local-lando-domain}' \"\$DIR\"
"

Fix in place (root-relative is what you want, same reasoning as Step 8):

ssh -i ~/.ssh/{ssh_key} ploi@{server_ip} "
  DIR={webroot}/modules/stsitebuilder/views/templates/front/template
  grep -rl '{local-lando-domain}' \"\$DIR\" | xargs sed -i \
    's#https\\?://{local-lando-domain}##g; s#//{local-lando-domain}#/#g'
  rm -rf {webroot}/var/cache/*
"

Then re-check the live page (curl -s https://{domain}/ | grep -o '{local-lando-domain}' | wc -l should be 0) — this is a case where fixing the DB and clearing every cache directory you can think of will all report success while the actual page keeps showing the bug, because none of that touches the real culprit.

Step 9 — Fix the memory_limit fatal in the backoffice

Symptom in the admin panel:

Allowed memory size of 134217728 bytes exhausted (tried to allocate ...)

This is not a sign the project needs more resources long-term — it's Symfony's Dependency Injection container compiling itself for the very first time (no cached container exists yet after a raw file copy), which is a one-time heavy operation, hitting PHP-FPM's default 128M memory_limit.

Two independent fixes, do both:

1. Raise the web memory_limit without needing root. /etc/php/*/fpm/php.ini is root-owned and Ploi SSH users commonly don't have passwordless sudo — but PHP-FPM honors a .user.ini file dropped at the site's webroot with no special privileges needed:

ssh -i ~/.ssh/{ssh_key} ploi@{server_ip} "echo 'memory_limit = 512M' > {webroot}/.user.ini"

Takes effect automatically within PHP's user_ini.cache_ttl (default 5 min), no service restart needed.

2. Pre-compile the container via CLI instead of a web request — PHP-CLI has no memory_limit restriction (php -i | grep memory_limit shows -1), so doing the compile here is both faster and avoids ever hitting the fatal at all:

ssh -i ~/.ssh/{ssh_key} ploi@{server_ip} "cd {webroot} && php bin/console cache:clear --env=prod --no-warmup && php bin/console cache:warmup --env=prod"

Do this on every fresh deploy where var/cache/ was excluded from the rsync (Step 4) or wiped — don't rely on the .user.ini bump alone to survive the first request gracefully.

Step 10 — Add the PrestaShop-aware nginx config (the big one)

This is the fix for the most confusing class of symptom this skill exists to document: a /adminXXX/login request 404s and the response body is the storefront theme's own 404 page — looks like a completely unrelated front-end bug, but it's nginx never routing the request to the admin app's index.php at all.

How to tell it's this, not something else

Diagnostic that removes all doubt (browser caching, CDNs, and "did it actually run" guesses all confound naive testing — this doesn't):

# 1. Note the current line count of the app's own log
ssh -i ~/.ssh/{ssh_key} ploi@{server_ip} "wc -l < {webroot}/var/logs/dev-*.log"
# 2. Hit the failing URL
curl -s -o /dev/null "https://{domain}/{admin_dir}/login?_token="
# 3. Check the line count again
ssh -i ~/.ssh/{ssh_key} ploi@{server_ip} "wc -l < {webroot}/var/logs/dev-*.log"

If the count didn't move, the request never reached PrestaShop's Symfony admin kernel at all — it's purely an nginx routing gap, full stop, regardless of what the rendered page looks like. Don't go chasing PHP version compatibility, cache staleness, or app-level bugs based on the rendered HTML alone; confirm with this log-growth check first. (A bare /{admin_dir}/ request, with no further path, usually does reach the admin kernel already, because nginx's index index.php; directive naturally serves the directory's own index.php for a literal directory match — this is what makes the bug easy to half-diagnose and then get stuck: the redirect to /login fires, then the next request silently falls through.)

Where to add it

Per-site, via Ploi panel → Site → Manage → NGINX → Server section (right-hand column) → + → new file, e.g. prestashop.conf. NOT the main site config file — the "Server" include folder is meant for exactly this kind of addition and is included before the site's own location ~ \.php$ block, so its regex locations win the match for admin/image paths without needing to touch or understand the rest of the generated file.

Don't also hand-add a single location ^~ /{admin_dir}/ { ... } block directly into the site's main config as an alternative/shortcut — it's tempting since it looks simpler, but it conflicts with the more specific regex-based routing below (which needs to distinguish {admin_dir}/index.php, non-index PHP files under {admin_dir} like filemanager/dialog.php, and clean asset/route paths from each other) and produces worse failure modes that are harder to debug than not having it at all.

The file

Adjust the fastcgi_pass socket to the site's actual PHP version (check {webroot}/.user.ini's sibling config or the site's PHP version in the panel).

# === PrestaShop nginx rules (admin routing + SEO image URLs) ===
# Ploi's generic template has no idea PrestaShop needs any of this — see
# ploi-prestashop-deploy skill for why.

fastcgi_read_timeout 300;
fastcgi_buffers 32 32k;
fastcgi_buffer_size 64k;

add_header Referrer-Policy        "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Permissions-Policy    "geolocation=(), microphone=(), camera=()" always;
fastcgi_hide_header X-Powered-By;

# PS9 bug: some module action links render without a leading slash, producing
# a nested duplicate admin path segment. Collapse it back to the real index.php.
rewrite ^/(admin[-_\w]*)/[^/]+/[^/]+.*/index\.php$ /$1/index.php last;

# Product image SEO URL → real disk path (PS splits by digit: id 191 -> img/p/1/9/1/191-x.ext)
rewrite ^/([0-9])(\-[\w-]+)?/.+\.(jpg|jpeg|png|gif|webp)$ /img/p/$1/$1$2.$3 last;
rewrite ^/([0-9])([0-9])(\-[\w-]+)?/.+\.(jpg|jpeg|png|gif|webp)$ /img/p/$1/$2/$1$2$3.$4 last;
rewrite ^/([0-9])([0-9])([0-9])(\-[\w-]+)?/.+\.(jpg|jpeg|png|gif|webp)$ /img/p/$1/$2/$3/$1$2$3$4.$5 last;
rewrite ^/([0-9])([0-9])([0-9])([0-9])(\-[\w-]+)?/.+\.(jpg|jpeg|png|gif|webp)$ /img/p/$1/$2/$3/$4/$1$2$3$4$5.$6 last;
rewrite ^/([0-9])([0-9])([0-9])([0-9])([0-9])(\-[\w-]+)?/.+\.(jpg|jpeg|png|gif|webp)$ /img/p/$1/$2/$3/$4/$5/$1$2$3$4$5$6.$7 last;
rewrite ^/([0-9])([0-9])([0-9])([0-9])([0-9])([0-9])(\-[\w-]+)?/.+\.(jpg|jpeg|png|gif|webp)$ /img/p/$1/$2/$3/$4/$5/$6/$1$2$3$4$5$6$7.$8 last;

# Category images: /c/35-name[/seo-subpath].ext -> /img/c/35-name.ext
# The optional non-capturing group discards a PS9 SEO subfolder before the extension.
rewrite ^/c/([0-9]+\-[\w-]+)(?:/.+)?\.(jpg|jpeg|png|gif|webp)$ /img/c/$1.$2 last;

rewrite ^/(\w+)-sitemap\.xml$ /sitemap.php?lang=$1 last;

location ~* ^/(config|app|bin|src|var|vendor)(/|$) { deny all; }
location ~* /\.env { deny all; }
location ~* ^/(composer\.(json|lock)|package(-lock)?\.json|yarn\.lock|Makefile)$ { deny all; }
location ~* \.(bak|sql|log|twig)$ { deny all; }
location ~* ^/(upload|img)/.*\.php[0-9]?$ { deny all; }

# Admin: any .php file other than index.php (e.g. filemanager/dialog.php).
# Must come before the clean-URLs block below, or nginx serves the file as a
# static download via try_files instead of executing it.
location ~* ^/(admin[-_\w]*)/(?!index\.php).+\.php(/|$) {
    fastcgi_pass unix:/run/php/php{PHP_VERSION}-fpm.sock;
    fastcgi_split_path_info ^(.+\.php)(/.*)$;
    fastcgi_index index.php;
    fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
    fastcgi_param DOCUMENT_ROOT $realpath_root;
    include fastcgi_params;
}

# Admin clean URLs (Symfony routes): serve static assets directly, else route to index.php
location ~* ^/(admin[-_\w]*)/(?!index\.php)(.+)$ {
    try_files $uri $uri/ /$1/index.php/$2$is_args$args;
}

# Admin index.php (PS9 Symfony routing entry point)
location ~* ^/(admin[-_\w]*)/index\.php(/|$) {
    fastcgi_pass unix:/run/php/php{PHP_VERSION}-fpm.sock;
    fastcgi_split_path_info ^(.+\.php)(/.*)$;
    fastcgi_index index.php;
    fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
    fastcgi_param DOCUMENT_ROOT $realpath_root;
    include fastcgi_params;
}

location ~* \.(gif|jpe?g|png|ico|svg|webp|css|js|woff2?|ttf|eot|otf)$ {
    try_files $uri /index.php?$query_string;
    expires 1M;
    add_header Cache-Control "public, immutable";
    add_header X-Content-Type-Options "nosniff" always;
    access_log off;
    log_not_found off;
}

admin[-_\w]* matches any admin folder name Ploi/PrestaShop generates (random hash or a custom rename like baupanel) automatically — you don't need to hardcode {admin_dir} anywhere in this file. If the project uses a completely different naming scheme that this regex misses, add it as an alternation: (admin[-_\w]*|customname).

Optional: rate limiting. A reference version of this file may include limit_req zone=prestashop_admin burst=30 nodelay; on the admin index.php block. That requires a limit_req_zone defined server-wide, e.g. in /etc/nginx/conf.d/rate-limits.conf:

limit_req_zone $binary_remote_addr zone=prestashop_admin:10m rate=120r/m;

This is root-owned config outside any single site's control — if the SSH account has no sudo, either ask whoever manages the server to add it once (it benefits every PrestaShop site on that box), or just drop the limit_req line from the site's prestashop.conf. Everything else in the file works fine without it.

robots.conf (staging/dev domains)

For any domain that isn't the real production one (a pre-testing.com, .dev, staging subdomain, etc.), also add this as a separate file in the same Server section, so it doesn't get indexed:

add_header X-Robots-Tag "noindex, nofollow, nosnippet, noarchive";

Verify the fix

Same log-growth check as above, now expecting the count to move:

LINES_BEFORE=$(ssh -i ~/.ssh/{ssh_key} ploi@{server_ip} "wc -l < {webroot}/var/logs/dev-*.log")
curl -s -o /dev/null "https://{domain}/{admin_dir}/login?_token="
LINES_AFTER=$(ssh -i ~/.ssh/{ssh_key} ploi@{server_ip} "wc -l < {webroot}/var/logs/dev-*.log")

LINES_AFTER > LINES_BEFORE confirms the admin kernel actually ran this time.

Step 11 — DNS and SSL

DNS A record → {server_ip}, before requesting SSL (Let's Encrypt validates over HTTP/DNS — if it doesn't resolve yet, the request just fails; wait for propagation rather than reaching for "force request / skip DNS verification").

Ploi panel → Site → SSL → type = Let's Encrypt, domain = {domain}. No DNS provider option needed unless using DNS-01 challenge for a wildcard.

Step 12 — Final verification

curl -s -o /dev/null -w "front=%{http_code}\n" https://{domain}/
curl -s -L -o /dev/null -w "admin=%{http_code} -> %{url_effective}\n" https://{domain}/{admin_dir}/
curl -s -o /dev/null -w "product image=%{http_code}\n" "https://{domain}/{some-product-id}-home_default/whatever.jpg"

Expected: front 200, admin final 200 at .../login?_token=, image 200.


Troubleshooting

Match the symptom, then jump to the linked step — don't re-derive the cause from scratch each time, it's always one of these on a fresh Ploi+PrestaShop deploy.

SymptomCauseFix
Admin /login 404s, page body looks like the storefront themenginx has no PrestaShop-aware admin routingStep 10
Product/category images 404 despite the file existing on disk at /img/p/.../...jpgnginx has no SEO-image-URL rewriteStep 10
Allowed memory size of 134217728 bytes exhausted in backofficefirst-time Symfony DI container compile hitting 128M defaultStep 9
Site loads then immediately redirects to a .lndo.site domainps_shop_url/ps_configuration still hold the local dev domain from the imported dumpStep 7
Some images/CMS blocks 404 even after Step 10, pointing at a .lndo.site URL in their srcabsolute domain baked into page-builder JSON contentStep 8
Menu links / images still show the old .lndo.site domain even though the DB check returns 0 rows and var/cache/* is emptycontent was statically exported to a real .tpl file on disk, not read from the DB at request timeStep 8b
DB import fails with Unknown collation: 'utf8mb...uca1400_ai_ci'local dump used a newer MySQL/MariaDB collation than the server supportsStep 5
Access denied for user on DB import/queries despite correct credentialstried the socket instead of TCP (-h127.0.0.1)Step 5
Not sure if a request even reached the app or died at nginx—the log-growth check in Step 10 works for any "did this request even run" question, not just admin login

General debugging principle for this stack

When something on a fresh Ploi PrestaShop site behaves unexpectedly and the cause isn't obvious, compare against a known-working PrestaShop site's Server includes list (Ploi panel → that site → Manage → NGINX → right column shows "Before"/"Server"/"After" files) rather than guessing at nginx internals from scratch. A working reference site's file list (e.g. it has a prestashop.conf and robots.conf that a broken one doesn't) is faster and more reliable evidence than reasoning about nginx location priority rules in the abstract — those rules are easy to reason about incorrectly (prefix vs regex precedence, error_page inheritance across locations, implicit directory-index behavior) even when each individual rule is well understood.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.