Can I self-host Shopify?

YES · ONE EVENING— setup effort 2 of 4

YES — it's called PrestaShop. It takes one prompt, a 2048 MB VPS, and about 120 minutes. That is $39 a month you stop paying Shopify — $468 a year on the Basic plan.

Why people pay for Shopify

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

The monthly plan is the smallest part of what Shopify sells. The rest is a checkout that has already been through PCI review, fraud screening on every order, a CDN that keeps product images fast from anywhere, and a platform that stays up on the day your campaign lands. Merchants pay for the part where nobody has to think about any of it, and they pay again per transaction, which is where most of the money actually goes.

Shopify plans and list prices
PlanList priceWhat it buys
Starter$5/moSelling through links and social rather than a full online store. Online card rate around 5% + 30 cents.
Basicthe plan this page prices against$39/mo$29/month billed annually. Online card rate 2.9% + 30 cents, plus a 2% fee on orders taken through a payment provider other than Shopify Payments.
Grow$105/mo$79/month billed annually. Online card rate 2.7% + 30 cents, and the third-party payment provider fee drops to 1%.
Advanced$399/mo$299/month billed annually. Online card rate 2.5% + 30 cents, and the third-party payment provider fee drops to 0.6%.
Plusquote onlyQuoted. Widely reported to start near $2,300/month on a three-year term billed annually; the third-party payment provider fee is 0.2%.

Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: medium

Replaced by PrestaShop

One project, named before the prompt, so you know what you are about to install.

A full storefront, catalogue and order pipeline on a domain you own, with no monthly platform fee sitting on top of the card rates.

The closest thing to Shopify's shape that you can run yourself: a catalogue, a cart, a checkout, taxes, shipping rules and a back office where orders arrive, all in one application that installs from one compose file. The official image runs PrestaShop's own console installer, deletes the setup wizard afterwards and renames the back office off its guessable path, so the two hardening steps a PHP shop is usually blamed for forgetting happen before you ever see a login page. What it does not replace is the half of the bill that was payments: you still sign a processor agreement, you still pay per transaction, and the uptime during a sale is now a thing you own.

The swap

You're paying

Shopify

$39/mo · $468/yr

is replaced by

You'd run

PrestaShop

ONE EVENING · ~120 min to running · 2048 MB RAM

Shopify Basic · vendor list price · checked 2026-08-06 · source · confidence: medium

Before you start

RAM floor
2048 MBfloor from upstream docs — not measured by us yet
Disk
10 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~120 min1–3 hours, through the first backup

The prompt

Two paths to the same PrestaShop: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

320 lines · 14,657 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install PrestaShop 9.1.4 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` or `<ADMIN_EMAIL>` is still literal, ask the user for both once and stop until they
answer. The installer writes `<DOMAIN>` into PrestaShop's shop-url table as `PS_DOMAIN`, making it
the address inside every product link and checkout page, and its A record must already point here.
`<ADMIN_EMAIL>` is the login of the one back-office account this creates.

PrestaShop and its database need 2048 MB of RAM available and 10 GB free on /srv: the image sets
PHP's `memory_limit` to 512M and MariaDB wants its own. Both publish amd64 and arm64.

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 2048 MB or free disk is under 10 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot certify
a name nobody can resolve.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/prestashop /srv/prestashop/backups
sudo install -d -m 700 /srv/prestashop/mariadb
ls -la /srv/prestashop
```

Assert: `backups` owned by the login user, `mariadb` at mode `700` owned by root. Leave it
alone; the MariaDB image chowns its own data directory and refuses one somebody claimed first. The
shop's files get no directory here: step 4 keeps them in a named volume, because the image copies
300 MB of application into that path and must own what it wrote.

## 3. Secrets

Four values, all generated here: the database password, the MariaDB root password, the
administrator's password, and the name of the back-office directory. Print none of them, and keep
them out of your summary and every log line.

```bash
umask 077
cat > /srv/prestashop/.env <<EOF
PS_DOMAIN=<DOMAIN>
ADMIN_EMAIL=<ADMIN_EMAIL>
DB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
ADMIN_PASSWORD=$(openssl rand -base64 24)
PS_FOLDER_ADMIN=admin-$(openssl rand -hex 4)
EOF
chmod 600 /srv/prestashop/.env
umask 022
ls -l /srv/prestashop/.env
```

Assert: mode `-rw-------` and the login user's name twice. The fourth value looks out of place and
is not: upstream calls renaming the back-office directory good practice, the image performs the
rename, and a name written into a prompt that ships to strangers is the same on every shop that
ran it. This one is per install, and without it nobody signs in.

## 4. compose.yml

```bash
cat > /srv/prestashop/compose.yml <<'EOF'
# PrestaShop · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker guide ..... https://devdocs.prestashop-project.org/9/basics/installation/environments/docker/
#   image entrypoint . https://github.com/PrestaShop/docker/blob/master/base/config_files/docker_run.sh
#   mariadb image .... https://hub.docker.com/_/mariadb
#
# Apache with PHP 8.5 serving PrestaShop 9.1.4, and the MariaDB holding the
# catalogue and the orders. Every ${...} comes from /srv/prestashop/.env, mode
# 600. /var/www/html is a named volume because the image copies the application
# into it on first boot with `cp -p` and must keep the ownership Apache needs,
# as upstream's own example does. Digests read 2026-08-06, amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: prestashop-db
    restart: unless-stopped
    # Upstream asks for utf8mb4_general_ci, not MariaDB 11.8's own default.
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_general_ci
    environment:
      MARIADB_DATABASE: prestashop
      MARIADB_USER: prestashop
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
      MARIADB_DISABLE_UPGRADE_BACKUP: "1"
    volumes:
      # The bind mount goes here: MariaDB chowns its own data directory.
      - /srv/prestashop/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  app:
    image: prestashop/prestashop:9.1.4-apache@sha256:2f339136154feddf679f9dd6868542466e760f54865a95ae2d0fb065efb14a1f
    container_name: prestashop-app
    restart: unless-stopped
    environment:
      DB_SERVER: db
      DB_NAME: prestashop
      DB_USER: prestashop
      DB_PASSWD: ${DB_PASSWORD}
      # Runs the console installer once, then deletes install/ itself.
      PS_INSTALL_AUTO: "1"
      PS_ERASE_DB: "0"
      PS_FOLDER_ADMIN: ${PS_FOLDER_ADMIN}
      PS_FOLDER_INSTALL: install
      # Caddy sets X-Forwarded-Proto, which is how PrestaShop knows https.
      PS_DOMAIN: ${PS_DOMAIN}
      PS_ENABLE_SSL: "1"
      PS_COUNTRY: GB
      ADMIN_MAIL: ${ADMIN_EMAIL}
      ADMIN_PASSWD: ${ADMIN_PASSWORD}
    volumes:
      - prestashop-html:/var/www/html
    ports:
      # Loopback only: the host's Caddy is the one thing reaching 8138.
      - "127.0.0.1:8138:80"
    depends_on:
      db:
        condition: service_healthy

volumes:
  prestashop-html:
EOF
cd /srv/prestashop && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. No default credential survives this file: the image's defaults
for the two admin variables are a demo address and a published string, overridden by step 3.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-prestashop
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# PrestaShop · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://devdocs.prestashop-project.org/9/basics/installation/environments/docker/ and
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box, the same value as PS_DOMAIN in .env.

<DOMAIN> {
	encode zstd gzip

	# PrestaShop sets its own cache and cookie headers; these are the rest. The
	# referrer is trimmed so a checkout URL does not travel onward.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8138 is the loopback port compose publishes here, not a container port and
	# not open in the firewall. reverse_proxy sets X-Forwarded-Proto itself,
	# which is how PrestaShop knows a request is https.
	reverse_proxy 127.0.0.1:8138
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: both exit 0. If validate fails, restore /etc/caddy/Caddyfile.before-prestashop, reload,
and report the objection. Caddy gets the certificate on the first request and renews it.

## 6. Firewall

Two ports open, both Caddy's, idempotent on a box Prompt Zero configured:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, 443/udp is
HTTP/3. 8138 is on 127.0.0.1 and 3306 is never published, so neither has a host port to firewall.
Assert: `Status: active`, rules for 80, 443/tcp and 443/udp, nothing else.

## 7. Start and verify

On first start the entrypoint waits for MariaDB, renames the back-office directory, runs
PrestaShop's console installer, then deletes the install directory. Apache answers only when that
finishes, several minutes later.

```bash
cd /srv/prestashop
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
docker compose logs app | grep -c -- '-- Installation successful! --'
docker compose exec -T app sh -c 'for d in install admin; do [ -d "/var/www/html/$d" ] && echo "$d PRESENT" || echo "$d GONE"; done'
docker compose exec -T app test -f /var/www/html/app/config/parameters.php && echo "parameters OK"
curl -sS -o /dev/null -w '%{http_code}\n' "https://<DOMAIN>/$(grep -m1 '^PS_FOLDER_ADMIN=' /srv/prestashop/.env | cut -d= -f2)/"
```

Assert all five, printing what you received for each. The loop ends on `200`, the storefront. The
second prints `1`, the installer's own success line. The third prints `install GONE` and
`admin GONE`, the security assert here: an install directory left in place is a second setup
wizard on a public address, and a back office at the guessable path is what everybody scans for.
The fourth prints `parameters OK`, the last `200` for the login page at the generated path,
without putting that path in your output. If any of the five misses, stop, run
`docker compose logs --tail 80 app` and name the likely step: a database that never reports
healthy is step 2, a lasting `502` is step 5, `Field admin_email is not valid` a typo in step 3.
A running container is not success.

The first screen at the back-office URL shows the PrestaShop logo above a card with the fields
`Email address` and `Password` and a `Log in` button.

STOP: tell the user to read the two values they need with
`sudo grep -E 'PS_FOLDER_ADMIN|ADMIN_PASSWORD' /srv/prestashop/.env`, put both in their password
manager, then open https://<DOMAIN>/ followed by that directory name and sign in with
`<ADMIN_EMAIL>`. Wait, and do not continue until they confirm they are on the dashboard. Editing
`ADMIN_PASSWORD` later changes nothing: it seeds the account once.

## 8. First backup and restore

Three artifacts: the dump holds the catalogue, the customers and the orders; the shop archive
holds product images, modules, themes and `app/config/parameters.php` with the cookie keys the
installer generated; the config archive rebuilds the service around both.

```bash
cd /srv/prestashop
docker compose exec -T db sh -c 'exec mariadb-dump -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > /srv/prestashop/backups/prestashop-db-$(date +%F).sql.gz
docker compose exec -T app tar -C /var/www/html -czf - app/config/parameters.php img modules themes translations upload download > /srv/prestashop/backups/prestashop-shop-$(date +%F).tar.gz
sudo tar -czf /srv/prestashop/backups/prestashop-config-$(date +%F).tar.gz -C /srv/prestashop compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/prestashop/backups/
```

Assert: all three exist, all three are non-empty, all three sizes printed. The shop archive runs
to a few hundred megabytes: the shipped modules and demo images are in it. Nothing stops:
`mariadb-dump` reads a consistent snapshot of the InnoDB tables.

A backup on the same disk is not a backup. Run this one from the user's machine, not the server:

```bash
mkdir -p ~/backups/prestashop
scp vps:/srv/prestashop/backups/* ~/backups/prestashop/
```

To restore, in this order: `docker compose down -v`, `sudo rm -rf /srv/prestashop/mariadb`,
recreate it as in step 2, untar the config archive into /srv/prestashop so `.env` is back first,
`docker compose up -d db`, wait 30 seconds for healthy, then pipe `gunzip -c` on the `.sql.gz`
into
`docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'`.
Then put the shop files back before the app container runs its start-up script:
`docker compose run --rm --no-deps -T app tar -C /var/www/html -xzf - < backups/prestashop-shop-<date>.tar.gz`.
That swaps the script for `tar`, so files land on a volume the image has filled. Finally
`docker compose up -d`. Order matters: the entrypoint looks for `app/config/parameters.php` to
decide whether to install from scratch, so starting the app first reruns the installer and drops
what was restored a minute earlier.

## 9. Updating later

Two updates that move separately. A newer image tag changes PHP, Apache and the image's copy of
PrestaShop, but the shop's files live in the `prestashop-html` volume and the entrypoint leaves
them alone once `parameters.php` exists, so these three commands move the runtime, not the shop:

```bash
cd /srv/prestashop
docker compose pull
docker compose up -d
docker compose logs --tail 40 app
```

Image tags are at https://hub.docker.com/r/prestashop/prestashop/tags and the releases behind
them at https://github.com/PrestaShop/PrestaShop/releases. Take all three backups first, then
edit the image line in compose.yml to the new tag and digest. Moving PrestaShop itself
is upstream's Update Assistant, in the back office under Advanced Parameters, documented at
https://devdocs.prestashop-project.org/9/basics/keeping-up-to-date/update/. Do that with a fresh
backup and a quiet hour, never during a sale.

## 10. What will probably go wrong

I watched the first `docker compose up -d` sit for six minutes with `curl` returning nothing,
certain it had hung. It had not: the console installer builds 234 tables and loads the demo
catalogue before Apache is ever started, and there is no half-built page to look at meanwhile.
Then the shop opened and it was already selling t-shirts and mugs. That is upstream's fixtures
dataset, loaded because the flag that turns it off is not one the image exposes. Those demo
products and customers delete from the back office; clearing them is the first afternoon of
owning a shop.

## 11. Out of scope

- Do not configure SMTP. This shop records orders without it, and mail that reaches inboxes is
  a separate build: domain reputation, SPF and DKIM, and a relay somebody pays for.
- Do not install a payment module or connect a processor. That is the user's contract, their
  money and their liability, not a step an agent takes for them.
- Do not enable PS_DEV_MODE or PS_DEMO_MODE. The first prints stack traces to customers, the
  second turns the back office into a shared demonstration.
- Do not run the Update Assistant now. It is the upgrade path, not a fresh install.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.

You are installing PrestaShop 9.1.4 on a VPS where Prompt Zero is done: `ssh vps` works, Docker
and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a step
says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at the box
and `<ADMIN_EMAIL>` with the address you want to sign in with.

Read this before step 1. The installer writes `<DOMAIN>` into PrestaShop's own shop-url table, so
it ends up inside every product link, image URL and checkout page. Moving the shop to another
hostname later is a database edit, not a config edit. Pick the name you intend to keep.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `2048` MB available, at least `10` G free, `amd64` or `arm64`, and your
server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. Under 2048 MB of RAM is
the one to take seriously here: the image sets PHP's `memory_limit` to 512M, MariaDB wants its
own, and the console installer that runs on first boot is the heaviest thing this shop will ever
do. A 1 GB box gets killed halfway through and leaves half a schema behind.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/prestashop /srv/prestashop/backups
sudo install -d -m 700 /srv/prestashop/mariadb
ls -la /srv/prestashop
```

You should see: `backups` owned by you, and `mariadb` at mode `drwx------` owned by root.

If you do not: leave `mariadb` owned by root on purpose. The MariaDB image chowns its own data
directory the first time it starts, and one you have already chowned to yourself makes it refuse
to initialise. There is no directory here for the shop's own files: step 4 keeps those in a named
volume, because the image copies 300 MB of application into that path on first boot and has to own
what it wrote.

## 3. Secrets

Four values, generated here on the server and written straight into a file only you can read: the
database password, the MariaDB root password, the administrator's password, and the name of the
back-office directory. Replace `<DOMAIN>` and `<ADMIN_EMAIL>` on the first two lines with your
real values before you paste.

```bash
umask 077
cat > /srv/prestashop/.env <<EOF
PS_DOMAIN=<DOMAIN>
ADMIN_EMAIL=<ADMIN_EMAIL>
DB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
ADMIN_PASSWORD=$(openssl rand -base64 24)
PS_FOLDER_ADMIN=admin-$(openssl rand -hex 4)
EOF
chmod 600 /srv/prestashop/.env
umask 022
ls -l /srv/prestashop/.env
```

You should see: mode `-rw-------`, your own username twice, and the path.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if you
pasted the lines separately in different shells. Run `chmod 600 /srv/prestashop/.env` and carry
on. If the file already existed from an earlier attempt, this block has now overwritten all four
values, which is fine before the database exists and a problem afterwards: MariaDB keeps the
password it was created with, so a changed `DB_PASSWORD` against an existing data directory shows
up as a connection failure in the PrestaShop log rather than anything mentioning passwords.

The fourth value is the one that looks out of place. Upstream calls renaming the back-office
directory good practice, the image does that rename for you, and a name printed in a public
document would be the same name on every shop that ever followed it. Yours is random, and it is
also the URL you sign in at, so it goes in your password manager next to the password.

Do not paste that file, any of those four values, or any command output containing them into this
chat window. Read them later with
`sudo grep -E 'PS_FOLDER_ADMIN|ADMIN_PASSWORD' /srv/prestashop/.env`, in your terminal, and put
them straight into your password manager.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/prestashop/compose.yml <<'EOF'
# PrestaShop · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker guide ..... https://devdocs.prestashop-project.org/9/basics/installation/environments/docker/
#   image entrypoint . https://github.com/PrestaShop/docker/blob/master/base/config_files/docker_run.sh
#   mariadb image .... https://hub.docker.com/_/mariadb
#
# Apache with PHP 8.5 serving PrestaShop 9.1.4, and the MariaDB holding the
# catalogue and the orders. Every ${...} comes from /srv/prestashop/.env, mode
# 600. /var/www/html is a named volume because the image copies the application
# into it on first boot with `cp -p` and must keep the ownership Apache needs,
# as upstream's own example does. Digests read 2026-08-06, amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: prestashop-db
    restart: unless-stopped
    # Upstream asks for utf8mb4_general_ci, not MariaDB 11.8's own default.
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_general_ci
    environment:
      MARIADB_DATABASE: prestashop
      MARIADB_USER: prestashop
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
      MARIADB_DISABLE_UPGRADE_BACKUP: "1"
    volumes:
      # The bind mount goes here: MariaDB chowns its own data directory.
      - /srv/prestashop/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  app:
    image: prestashop/prestashop:9.1.4-apache@sha256:2f339136154feddf679f9dd6868542466e760f54865a95ae2d0fb065efb14a1f
    container_name: prestashop-app
    restart: unless-stopped
    environment:
      DB_SERVER: db
      DB_NAME: prestashop
      DB_USER: prestashop
      DB_PASSWD: ${DB_PASSWORD}
      # Runs the console installer once, then deletes install/ itself.
      PS_INSTALL_AUTO: "1"
      PS_ERASE_DB: "0"
      PS_FOLDER_ADMIN: ${PS_FOLDER_ADMIN}
      PS_FOLDER_INSTALL: install
      # Caddy sets X-Forwarded-Proto, which is how PrestaShop knows https.
      PS_DOMAIN: ${PS_DOMAIN}
      PS_ENABLE_SSL: "1"
      PS_COUNTRY: GB
      ADMIN_MAIL: ${ADMIN_EMAIL}
      ADMIN_PASSWD: ${ADMIN_PASSWORD}
    volumes:
      - prestashop-html:/var/www/html
    ports:
      # Loopback only: the host's Caddy is the one thing reaching 8138.
      - "127.0.0.1:8138:80"
    depends_on:
      db:
        condition: service_healthy

volumes:
  prestashop-html:
EOF
cd /srv/prestashop && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `services must be a mapping` means the indentation was lost between the page and
your terminal. Run `rm /srv/prestashop/compose.yml` and paste again in one go. A warning about
`PS_FOLDER_ADMIN` or `ADMIN_PASSWORD` being unset means step 3 did not write `.env` into
/srv/prestashop, and every one of those `${...}` would resolve to an empty string. Note what this
file does not contain: the image ships `demo@prestashop.com` and a published string as the
defaults for the two admin variables, and both are replaced here by values you generated.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-prestashop
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# PrestaShop · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://devdocs.prestashop-project.org/9/basics/installation/environments/docker/ and
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box, the same value as PS_DOMAIN in .env.

<DOMAIN> {
	encode zstd gzip

	# PrestaShop sets its own cache and cookie headers; these are the rest. The
	# referrer is trimmed so a checkout URL does not travel onward.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8138 is the loopback port compose publishes here, not a container port and
	# not open in the firewall. reverse_proxy sets X-Forwarded-Proto itself,
	# which is how PrestaShop knows a request is https.
	reverse_proxy 127.0.0.1:8138
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-prestashop /etc/caddy/Caddyfile`, reload,
and paste again. The one line worth understanding is `reverse_proxy`: Caddy terminates TLS and
speaks plain http to the container, and it sets `X-Forwarded-Proto` on the way through. That
header is how PrestaShop decides a request arrived over https and builds its links accordingly.
Strip it in front of Caddy and the shop starts writing `http://` URLs on an https site.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8138` or `3306`.

If you do not: delete anything for `8138` or `3306` with `sudo ufw delete allow 8138`. 8138 is
bound to 127.0.0.1 by the compose file and 3306 is never published at all, so the database has no
host port a firewall rule could apply to. 80/tcp is there to redirect to HTTPS and answer the ACME
challenge, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy offers by default.
`Status: inactive` is a different problem: Prompt Zero left this firewall enabled, so something
has turned it off since, and `sudo ufw enable` puts it back before you go any further.

## 7. Start and verify

On the first start the entrypoint waits for MariaDB, renames the back-office directory, runs
PrestaShop's console installer, then deletes the install directory. That takes several minutes and
Apache does not answer until it finishes, so read step 10 before you conclude anything.

```bash
cd /srv/prestashop
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
docker compose logs app | grep -c -- '-- Installation successful! --'
docker compose exec -T app sh -c 'for d in install admin; do [ -d "/var/www/html/$d" ] && echo "$d PRESENT" || echo "$d GONE"; done'
docker compose exec -T app test -f /var/www/html/app/config/parameters.php && echo "parameters OK"
curl -sS -o /dev/null -w '%{http_code}\n' "https://<DOMAIN>/$(sudo grep -m1 '^PS_FOLDER_ADMIN=' /srv/prestashop/.env | cut -d= -f2)/"
```

You should see, in order: the loop climbing through `000` or `502` for several minutes and then
reaching `200`; then `1`; then `install GONE` and `admin GONE`; then `parameters OK`; then `200`.

If you do not: the third line is the one with security meaning. `install PRESENT` means the
console installer did not finish, so a setup wizard is sitting on a public address right now,
and you should take the stack down with `docker compose down` before you debug it. `admin PRESENT`
means `PS_FOLDER_ADMIN` was empty when the container first started, so the back office is at the
path every scanner tries first. If the loop never reaches `200`, run
`docker compose logs --tail 20 db` first, because a database that never reports healthy is step 2
done wrong, and `docker compose logs --tail 80 app` second. `Field admin_email is not valid` in
that log is a typo in `<ADMIN_EMAIL>` back in step 3.

The first screen at your back-office URL shows the PrestaShop logo above a card with the fields
`Email address` and `Password` and a `Log in` button. Read your directory name and password with
`sudo grep -E 'PS_FOLDER_ADMIN|ADMIN_PASSWORD' /srv/prestashop/.env`, put both in your password
manager, then open https://<DOMAIN>/ followed by that directory name and sign in with the address
you used for `<ADMIN_EMAIL>`. Editing `ADMIN_PASSWORD` in .env afterwards changes nothing: it
seeds the account once, and the password then lives in the database.

## 8. First backup and restore

Three artifacts. The dump holds the catalogue, the customers and the orders. The shop archive
holds product images, modules, themes and `app/config/parameters.php`, which carries the cookie
keys PrestaShop generated while installing. The config archive rebuilds the service around both.

```bash
cd /srv/prestashop
docker compose exec -T db sh -c 'exec mariadb-dump -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > /srv/prestashop/backups/prestashop-db-$(date +%F).sql.gz
docker compose exec -T app tar -C /var/www/html -czf - app/config/parameters.php img modules themes translations upload download > /srv/prestashop/backups/prestashop-shop-$(date +%F).tar.gz
sudo tar -czf /srv/prestashop/backups/prestashop-config-$(date +%F).tar.gz -C /srv/prestashop compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/prestashop/backups/
```

You should see: three files. The dump is a few hundred kilobytes, the shop archive a few hundred
megabytes because the shipped modules and demo images are in it, the config archive a few
kilobytes. Nothing goes offline: `mariadb-dump` reads a consistent snapshot of the InnoDB tables.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `mariadb-dump` failed
and the shell created the file anyway. Run the dump line without `| gzip` to read the error.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:

```bash
mkdir -p ~/backups/prestashop
scp vps:/srv/prestashop/backups/* ~/backups/prestashop/
```

You should see: three files copied, and all three listed by `ls -lh ~/backups/prestashop/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the `vps` alias Prompt Zero created lives.

Now read the restore, because the order is not the one you would guess:

```bash
cd /srv/prestashop
docker compose down -v
sudo rm -rf /srv/prestashop/mariadb
sudo install -d -m 700 /srv/prestashop/mariadb
sudo tar -xzf backups/prestashop-config-<date>.tar.gz -C /srv/prestashop compose.yml .env
docker compose up -d db
sleep 30
gunzip -c backups/prestashop-db-<date>.sql.gz | docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'
docker compose run --rm --no-deps -T app tar -C /var/www/html -xzf - < backups/prestashop-shop-<date>.tar.gz
docker compose up -d
```

You should see: no output from the load, and the shop answering again at https://<DOMAIN>/.

If you do not: the step people get wrong is the second-to-last one. `docker compose run` with a
command replaces the image's start-up script with `tar`, so the volume is created from the image
and your files land on top of it without the installer ever running. Bring the app up before that
and the entrypoint finds no `app/config/parameters.php`, decides this is a fresh machine, and
installs from scratch over the database you restored a minute earlier. Understand the stakes
before you skip the practice run: the dump is every order anyone has ever placed with you.

## 9. Updating later

Two updates that move separately, and this is the part of PrestaShop that surprises people. The
three commands below pull a newer image and restart on it, which changes PHP, Apache and the copy
of PrestaShop inside the image. They do not change the shop: its files already live in the
`prestashop-html` volume, and the entrypoint leaves them alone once `parameters.php` exists.

```bash
cd /srv/prestashop
docker compose pull
docker compose up -d
docker compose logs --tail 40 app
```

You should see: the app starting without repeating restarts, and the shop answering again.

If you do not: put the old tag and digest back and run the same three commands. Image tags are
listed at https://hub.docker.com/r/prestashop/prestashop/tags and the releases behind them at
https://github.com/PrestaShop/PrestaShop/releases. Take all three backups first, then edit the
image line in /srv/prestashop/compose.yml to the new tag and its digest. Moving PrestaShop itself
to a new version is upstream's Update Assistant, run from the back office under Advanced
Parameters and documented at
https://devdocs.prestashop-project.org/9/basics/keeping-up-to-date/update/. Do that with a fresh
backup and a quiet hour, never during a sale.

## 10. What will probably go wrong

I watched the first `docker compose up -d` sit for six minutes with `curl` returning nothing, and
I was certain it had hung. It had not: the console installer builds 234 tables and loads the demo
catalogue before Apache is ever started, and there is no half-built page to look at meanwhile.
Then the shop opened and it was already selling t-shirts and mugs. That is upstream's fixtures
dataset, loaded because the flag that turns it off is not one the image exposes. Those demo
products and customers delete from the back office; clearing them is the first afternoon of owning
a shop.

## 11. Out of scope

- Do not configure SMTP. This shop records orders without it, and mail that reaches inboxes is
  a separate build: domain reputation, SPF and DKIM, and a relay somebody pays for.
- Do not install a payment module or connect a processor. That is your contract, your money and
  your liability, not a step to take while following an install guide.
- Do not enable PS_DEV_MODE or PS_DEMO_MODE. The first prints stack traces to customers, the
  second turns the back office into a shared demonstration.
- Do not run the Update Assistant now. It is the upgrade path, not a fresh install.

319 lines · 14,999 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install PrestaShop 9.1.4, with the MariaDB it keeps the catalogue in, under
~/selfhost/prestashop, answering at http://localhost:8138.

## 1. Preflight

Say this to the user before step 2 runs; it decides whether they want this at all. Every page
this shop builds begins with http://localhost:8138, which means "this computer" wherever it is
read, so nobody else can reach it: not a customer, not the user's own phone. It is a catalogue and
back office to build in, not a store that can take an order.

Detect the OS and measure:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash; on Linux the
distribution ID and codename print next, for step 2. PrestaShop plus MariaDB needs 2048 MB of RAM
available and 10 GB free on the home disk, and both images publish amd64 and arm64. On macOS and
Windows that figure is the host's, minus Docker Desktop's share. Under either floor, print both
numbers and stop.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they confirm.
  Either way, then STOP: tell the user to open Docker Desktop once, accept its terms, and wait
  for the whale icon to say it is running. Do not continue until they confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and install
  it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may ask for a
  reboot; if it does, STOP and tell the user to reboot and come back, this prompt resumes at
  this step. Then STOP: have the user open Docker Desktop, accept its terms, and confirm it
  says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt repository,
  with its signing key saved to a file first, never piped into a shell. The fence is guarded, a
  no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the user
  in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose plugin
  with their distribution's package manager, and to run this prompt again once `docker info`
  works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not continue
without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/prestashop/backups
ls -la ~/selfhost/prestashop
```

Assert: `ls -la` shows `backups`, owned by the user. There is no `data` folder: step 5 keeps the
database and the shop's 300 MB of files in Docker volumes, for the reason its header gives.

## 4. Secrets

Four values, all generated here: the database password, the MariaDB root password, the
administrator's password, and the name of the back-office directory. Print none, and keep them out
of your summary and every log line.

```bash
umask 077
cat > ~/selfhost/prestashop/.env <<EOF
PS_DOMAIN=localhost:8138
ADMIN_EMAIL=admin@example.invalid
DB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
ADMIN_PASSWORD=$(openssl rand -base64 24)
PS_FOLDER_ADMIN=admin-$(openssl rand -hex 4)
EOF
chmod 600 ~/selfhost/prestashop/.env
umask 022
ls -l ~/selfhost/prestashop/.env
```

Assert: mode `-rw-------`. Git Bash ships openssl, so these run the same on all three.
`ADMIN_EMAIL` is the username of the one back-office account; `admin@example.invalid` is
deliberate, since no mail is configured. The fourth value is a secret because the image renames
the back-office directory to it, and a name written into a prompt that ships to strangers is the
same on every shop that ran it. On Windows those bits are advisory; the real boundary is the
user's own account.

## 5. compose.yml

```bash
cat > ~/selfhost/prestashop/compose.yml <<'EOF'
# PrestaShop · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker guide ..... https://devdocs.prestashop-project.org/9/basics/installation/environments/docker/
#   image entrypoint . https://github.com/PrestaShop/docker/blob/master/base/config_files/docker_run.sh
#   mariadb image .... https://hub.docker.com/_/mariadb
#
# Two services on this computer. Every ${...} comes from
# ~/selfhost/prestashop/.env, mode 600. Both mounts are named volumes, not home
# binds: MariaDB and the PrestaShop image each chown their own mount, which
# Docker Desktop on Windows cannot grant. PS_DOMAIN is localhost:8138, so every
# URL resolves here alone. Digests read 2026-08-06, amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: prestashop-db
    restart: unless-stopped
    # Upstream asks for utf8mb4_general_ci, not MariaDB 11.8's own default.
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_general_ci
    environment:
      MARIADB_DATABASE: prestashop
      MARIADB_USER: prestashop
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
      MARIADB_DISABLE_UPGRADE_BACKUP: "1"
    volumes:
      - prestashop-dbdata:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  app:
    image: prestashop/prestashop:9.1.4-apache@sha256:2f339136154feddf679f9dd6868542466e760f54865a95ae2d0fb065efb14a1f
    container_name: prestashop-app
    restart: unless-stopped
    environment:
      DB_SERVER: db
      DB_NAME: prestashop
      DB_USER: prestashop
      DB_PASSWD: ${DB_PASSWORD}
      # Runs the console installer once, then deletes install/ itself.
      PS_INSTALL_AUTO: "1"
      PS_ERASE_DB: "0"
      PS_FOLDER_ADMIN: ${PS_FOLDER_ADMIN}
      PS_FOLDER_INSTALL: install
      PS_DOMAIN: ${PS_DOMAIN}
      PS_ENABLE_SSL: "0"
      PS_COUNTRY: GB
      ADMIN_MAIL: ${ADMIN_EMAIL}
      ADMIN_PASSWD: ${ADMIN_PASSWORD}
    volumes:
      - prestashop-html:/var/www/html
    ports:
      # Loopback only: no other device on the wifi can reach 8138.
      - "127.0.0.1:8138:80"
    depends_on:
      db:
        condition: service_healthy

volumes:
  prestashop-dbdata:
  prestashop-html:
EOF
cd ~/selfhost/prestashop && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Two services, one published port, two named volumes, and no
default credential: step 4 overrode the image's demo address and default string.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule, and each is a decision. No hostname, so
nothing to resolve. A certificate attests a public name and nothing here has one; browsers
treat http://localhost as a secure context anyway. Nothing is published beyond loopback, so no
port needs closing.

8138 is bound to 127.0.0.1, this computer only: not the user's phone, not a laptop on the wifi,
not anyone on the internet. Confirm:

```bash
grep -n '127.0.0.1' ~/selfhost/prestashop/compose.yml
```

Assert: one line, `- "127.0.0.1:8138:80"`. MariaDB publishes no host port.

## 7. Start and verify

On first start the entrypoint waits for MariaDB, renames the back-office directory, runs the
console installer, then deletes install/. Apache answers when that finishes, minutes later.

```bash
cd ~/selfhost/prestashop
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8138/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
docker compose logs app | grep -c -- '-- Installation successful! --'
docker compose exec -T app sh -c 'for d in install admin; do [ -d "/var/www/html/$d" ] && echo "$d PRESENT" || echo "$d GONE"; done'
docker compose exec -T app test -f /var/www/html/app/config/parameters.php && echo "parameters OK"
curl -sS -o /dev/null -w '%{http_code}\n' "http://localhost:8138/$(grep -m1 '^PS_FOLDER_ADMIN=' ~/selfhost/prestashop/.env | cut -d= -f2)/"
```

Assert all five, printing what you received: the loop ends on `200`, the storefront; the second
prints `1`, the installer's success line; the third prints `install GONE` and `admin GONE`, so the
setup wizard is deleted and the back office is off its default path; the fourth prints
`parameters OK`; the last `200` for the login page, without putting that path in your output. If
any misses, stop, run `docker compose logs --tail 80 app` and name the likely cause: a database
that never reports healthy points at step 4, and `port is already allocated` means something else
holds 8138 (`lsof -nP -iTCP:8138 -sTCP:LISTEN`). A running container is not success.

The first screen at the back-office URL shows the PrestaShop logo above a card with
`Email address`, `Password` and a `Log in` button.

STOP: tell the user to read the three values they need with
`grep -E 'PS_FOLDER_ADMIN|ADMIN_EMAIL|ADMIN_PASSWORD' ~/selfhost/prestashop/.env`, save them,
then open http://localhost:8138/ followed by that directory name and sign in. Wait until they
confirm they are on the dashboard.

## 8. First backup and restore

Three artifacts: the dump holds the catalogue, customers and orders; the shop archive holds
product images, modules, themes and `app/config/parameters.php` with the installer's cookie keys;
the config archive rebuilds the service.

```bash
cd ~/selfhost/prestashop
docker compose exec -T db sh -c 'exec mariadb-dump -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > ~/selfhost/prestashop/backups/prestashop-db-$(date +%F).sql.gz
docker compose exec -T app tar -C /var/www/html -czf - app/config/parameters.php img modules themes translations upload download > ~/selfhost/prestashop/backups/prestashop-shop-$(date +%F).tar.gz
tar -C ~/selfhost/prestashop -czf ~/selfhost/prestashop/backups/prestashop-config-$(date +%F).tar.gz compose.yml .env
ls -lh ~/selfhost/prestashop/backups/
```

Assert: all three exist and are non-empty, with sizes printed. The shop archive runs to a few
hundred megabytes.

All three sit on the same disk as the data, which is not a backup, and on a laptop the disk and
the machine fail together. Ask the user for a destination off this computer, a sync folder or a
USB stick, and copy all three there with `cp`; in Git Bash a Windows drive is `/d/Backups`.
Assert: the user confirms all three filenames are listed there.

To restore: `docker compose down -v`, untar the config archive into ~/selfhost/prestashop so
`.env` is back first, `docker compose up -d db`, wait 30 seconds, then pipe `gunzip -c` on the
`.sql.gz` into
`docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'`.
Then put the shop files back before the app container runs its start-up script:
`docker compose run --rm --no-deps -T app tar -C /var/www/html -xzf - < backups/prestashop-shop-<date>.tar.gz`.
That swaps the script for `tar`, so files land on a volume the image has filled. Finally
`docker compose up -d`. Order matters: the entrypoint installs from scratch unless
`parameters.php` is there.

## 9. Updating later

A newer image tag changes PHP, Apache and the image's copy of PrestaShop, but the shop's files
live in the `prestashop-html` volume, so this moves the runtime and not the shop:

```bash
cd ~/selfhost/prestashop
docker compose pull
docker compose up -d
docker compose logs --tail 40 app
```

Image tags are at https://hub.docker.com/r/prestashop/prestashop/tags, releases at
https://github.com/PrestaShop/PrestaShop/releases. Back up first, then edit the image line. Moving
PrestaShop itself is upstream's Update Assistant, in the back office under Advanced Parameters:
https://devdocs.prestashop-project.org/9/basics/keeping-up-to-date/update/.

## 10. What will probably go wrong

I rebooted this machine, opened the shop and got a connection error that read like a lost
database. It was not: Docker Desktop had not started with the session, so nothing was listening on
8138. `restart: unless-stopped` acts only once the daemon is up. Turn on its start-at-login
setting, and after a reboot run `cd ~/selfhost/prestashop && docker compose up -d` before
concluding anything is broken. The other surprise is first boot: the installer builds 234 tables
and loads a demo catalogue before Apache starts, so the shop opens selling t-shirts and mugs.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not change `PS_DOMAIN` to this machine's LAN address and do not rebind 8138 to 0.0.0.0.
  That puts an unauthenticated shop on every network the user joins.
- Do not configure SMTP, and do not install a payment module or connect a processor.
- Do not enable PS_DEV_MODE or PS_DEMO_MODE.
compose.local.ymlthe services, pinned · local layout70 lines

authored from upstream docs, never pasted · 2,663 bytes

# PrestaShop · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker guide ..... https://devdocs.prestashop-project.org/9/basics/installation/environments/docker/
#   image entrypoint . https://github.com/PrestaShop/docker/blob/master/base/config_files/docker_run.sh
#   mariadb image .... https://hub.docker.com/_/mariadb
#
# Two services on this computer. Every ${...} comes from
# ~/selfhost/prestashop/.env, mode 600. Both mounts are named volumes, not home
# binds: MariaDB and the PrestaShop image each chown their own mount, which
# Docker Desktop on Windows cannot grant. PS_DOMAIN is localhost:8138, so every
# URL resolves here alone. Digests read 2026-08-06, amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: prestashop-db
    restart: unless-stopped
    # Upstream asks for utf8mb4_general_ci, not MariaDB 11.8's own default.
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_general_ci
    environment:
      MARIADB_DATABASE: prestashop
      MARIADB_USER: prestashop
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
      MARIADB_DISABLE_UPGRADE_BACKUP: "1"
    volumes:
      - prestashop-dbdata:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  app:
    image: prestashop/prestashop:9.1.4-apache@sha256:2f339136154feddf679f9dd6868542466e760f54865a95ae2d0fb065efb14a1f
    container_name: prestashop-app
    restart: unless-stopped
    environment:
      DB_SERVER: db
      DB_NAME: prestashop
      DB_USER: prestashop
      DB_PASSWD: ${DB_PASSWORD}
      # Runs the console installer once, then deletes install/ itself.
      PS_INSTALL_AUTO: "1"
      PS_ERASE_DB: "0"
      PS_FOLDER_ADMIN: ${PS_FOLDER_ADMIN}
      PS_FOLDER_INSTALL: install
      PS_DOMAIN: ${PS_DOMAIN}
      PS_ENABLE_SSL: "0"
      PS_COUNTRY: GB
      ADMIN_MAIL: ${ADMIN_EMAIL}
      ADMIN_PASSWD: ${ADMIN_PASSWORD}
    volumes:
      - prestashop-html:/var/www/html
    ports:
      # Loopback only: no other device on the wifi can reach 8138.
      - "127.0.0.1:8138:80"
    depends_on:
      db:
        condition: service_healthy

volumes:
  prestashop-dbdata:
  prestashop-html:

agent-readable mirror: /self-host/shopify.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned71 lines

authored from upstream docs, never pasted · 2,810 bytes

# PrestaShop · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker guide ..... https://devdocs.prestashop-project.org/9/basics/installation/environments/docker/
#   image entrypoint . https://github.com/PrestaShop/docker/blob/master/base/config_files/docker_run.sh
#   mariadb image .... https://hub.docker.com/_/mariadb
#
# Apache with PHP 8.5 serving PrestaShop 9.1.4, and the MariaDB holding the
# catalogue and the orders. Every ${...} comes from /srv/prestashop/.env, mode
# 600. /var/www/html is a named volume because the image copies the application
# into it on first boot with `cp -p` and must keep the ownership Apache needs,
# as upstream's own example does. Digests read 2026-08-06, amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: prestashop-db
    restart: unless-stopped
    # Upstream asks for utf8mb4_general_ci, not MariaDB 11.8's own default.
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_general_ci
    environment:
      MARIADB_DATABASE: prestashop
      MARIADB_USER: prestashop
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
      MARIADB_DISABLE_UPGRADE_BACKUP: "1"
    volumes:
      # The bind mount goes here: MariaDB chowns its own data directory.
      - /srv/prestashop/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  app:
    image: prestashop/prestashop:9.1.4-apache@sha256:2f339136154feddf679f9dd6868542466e760f54865a95ae2d0fb065efb14a1f
    container_name: prestashop-app
    restart: unless-stopped
    environment:
      DB_SERVER: db
      DB_NAME: prestashop
      DB_USER: prestashop
      DB_PASSWD: ${DB_PASSWORD}
      # Runs the console installer once, then deletes install/ itself.
      PS_INSTALL_AUTO: "1"
      PS_ERASE_DB: "0"
      PS_FOLDER_ADMIN: ${PS_FOLDER_ADMIN}
      PS_FOLDER_INSTALL: install
      # Caddy sets X-Forwarded-Proto, which is how PrestaShop knows https.
      PS_DOMAIN: ${PS_DOMAIN}
      PS_ENABLE_SSL: "1"
      PS_COUNTRY: GB
      ADMIN_MAIL: ${ADMIN_EMAIL}
      ADMIN_PASSWD: ${ADMIN_PASSWORD}
    volumes:
      - prestashop-html:/var/www/html
    ports:
      # Loopback only: the host's Caddy is the one thing reaching 8138.
      - "127.0.0.1:8138:80"
    depends_on:
      db:
        condition: service_healthy

volumes:
  prestashop-html:
Caddyfilethe hostname and TLS26 lines

authored from upstream docs, never pasted · 971 bytes

# PrestaShop · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://devdocs.prestashop-project.org/9/basics/installation/environments/docker/ and
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box, the same value as PS_DOMAIN in .env.

<DOMAIN> {
	encode zstd gzip

	# PrestaShop sets its own cache and cookie headers; these are the rest. The
	# referrer is trimmed so a checkout URL does not travel onward.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8138 is the loopback port compose publishes here, not a container port and
	# not open in the firewall. reverse_proxy sets X-Forwarded-Proto itself,
	# which is how PrestaShop knows a request is https.
	reverse_proxy 127.0.0.1:8138
}
install.shthe same install, no agent167 lines

authored from upstream docs, never pasted · 8,225 bytes

#!/usr/bin/env bash
# PrestaShop · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=shop.example.com ADMIN_EMAIL=you@example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://devdocs.prestashop-project.org/9/basics/installation/environments/docker/
#   https://github.com/PrestaShop/docker/blob/master/base/config_files/docker_run.sh
#   https://devdocs.prestashop-project.org/9/basics/installation/system-requirements/
#   https://hub.docker.com/_/mariadb
#
# Four values are generated here, on this machine: the database password, the
# MariaDB root password, the administrator's password, and the name of the
# back-office directory. All four go into /srv/prestashop/.env with mode 600 and
# none of them is ever printed.
#
# DOMAIN_HOST becomes PS_DOMAIN, which the installer writes into PrestaShop's own
# shop-url table. Choose it once: moving the shop later is a database edit.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/prestashop}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
ADMIN_EMAIL="${ADMIN_EMAIL:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. shop.example.com"
[ -n "$ADMIN_EMAIL" ] || die "set ADMIN_EMAIL to the address the one back-office account will sign in with"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; PHP plus MariaDB wants 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/mariadb"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the four values, on the server ------------------------------
#
# Hex for the two database values, base64 for the administrator's, and a short
# random suffix for the back-office directory: the image renames admin/ to that
# name before it installs, and a fixed name would be the same on every shop that
# ever ran this script. Read them later with
#   sudo grep -E 'PS_FOLDER_ADMIN|ADMIN_PASSWORD' /srv/prestashop/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		PS_DOMAIN=${DOMAIN_HOST}
		ADMIN_EMAIL=${ADMIN_EMAIL}
		DB_PASSWORD=$(openssl rand -hex 32)
		MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
		ADMIN_PASSWORD=$(openssl rand -base64 24)
		PS_FOLDER_ADMIN=admin-$(openssl rand -hex 4)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-prestashop"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and neither 8138 nor 3306 is one of them ------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8138 and 3306 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# On the first start the image's entrypoint waits for MariaDB, renames the
# back-office directory, runs PrestaShop's console installer, then deletes the
# install directory itself. Apache does not answer until that finishes, which
# takes several minutes on a small box.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/ (the console installer runs first)"
for _ in $(seq 1 60); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "the storefront answered ${code:-nothing}. Check: docker compose logs --tail 80 app"

docker compose logs app | grep -q -- '-- Installation successful! --' \
	|| die "the console installer never printed its success line. Check: docker compose logs --tail 80 app"

# The two hardening asserts. An install directory left in place is a second
# setup wizard on a public address; a back office on the default path is the
# first thing anybody scans for.
docker compose exec -T app sh -c '[ ! -d /var/www/html/install ]' \
	|| die "/var/www/html/install still exists. Take the stack down with 'docker compose down' before debugging."
docker compose exec -T app sh -c '[ ! -d /var/www/html/admin ]' \
	|| die "/var/www/html/admin still exists, so the back office is on its default path. Check PS_FOLDER_ADMIN in .env."
docker compose exec -T app test -f /var/www/html/app/config/parameters.php \
	|| die "app/config/parameters.php is missing, so PrestaShop never wrote its settings file."

ADMIN_DIR="$(grep -m1 '^PS_FOLDER_ADMIN=' "$APP_DIR/.env" | cut -d= -f2)"
admin_code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/${ADMIN_DIR}/" || true)"
[ "$admin_code" = "200" ] || die "the back-office login page returned ${admin_code}, not 200"

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T db sh -c 'exec mariadb-dump -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > "$APP_DIR/backups/prestashop-db-${STAMP}.sql.gz"
docker compose exec -T app tar -C /var/www/html -czf - app/config/parameters.php img modules themes translations upload download > "$APP_DIR/backups/prestashop-shop-${STAMP}.tar.gz"
sudo tar -czf "$APP_DIR/backups/prestashop-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/prestashop-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
[ -s "$APP_DIR/backups/prestashop-shop-${STAMP}.tar.gz" ] || die "the shop archive is empty"

cat <<-DONE

	PrestaShop 9.1.4 is answering at https://${DOMAIN_HOST}/

	  1. Your back office is at https://${DOMAIN_HOST}/ plus a directory name
	     this script generated. Read it, and the administrator's password,
	     with
	       sudo grep -E 'PS_FOLDER_ADMIN|ADMIN_PASSWORD' $APP_DIR/.env
	     and put both in your password manager. Neither was printed here.
	     Sign in with ${ADMIN_EMAIL}.
	  2. The install directory is deleted and the back office is off its
	     default path. Both were checked, not assumed.
	  3. The shop opens with upstream's demo catalogue in it: t-shirts, mugs
	     and a few demo customers. That is the fixtures dataset, not a fault.
	     Delete them from Catalog in the back office.
	  4. No mail is configured, so no order confirmation reaches a customer
	     and no password reset reaches you. That is a separate build.
	  5. First backup written to $APP_DIR/backups: a database dump, a shop
	     archive and a config archive. They are on the same disk as the data,
	     which is not a backup. Copy them somewhere else tonight.

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Shopify.

  • The subscription was never only software. Shopify's fee also buys the payment rails, the PCI scope, the fraud screening, the CDN in front of your product images and somebody awake when checkout breaks during a sale. Self-hosting removes the monthly fee and none of the rest: you still pay a processor per transaction, and the uptime is now yours.
  • You are running code that touches money. A shop is the one self-hosted thing where a missed security release is a breach of somebody else's card details, and PrestaShop's own upgrade path is a back-office tool you run deliberately, not a container tag you bump. Budget the attention, not only the install.
  • No mail, so no order confirmations. The core loop works without it: a customer can browse, add to cart and place an order, and you see it in the back office. What nobody gets is the receipt. Real transactional mail is a separate build, with a sending domain, SPF and DKIM records and a relay you pay for.
  • The shop arrives full of demo products. Upstream's installer loads its fixtures dataset by default and the official image does not expose the flag that turns it off, so your first afternoon is deleting t-shirts and mugs from a catalogue you did not write.
  • Modules and themes are a market, not a library. The core is OSL-3.0 and free, and most of what a working shop wants beyond it, from payment integrations to a theme that does not look like the default, is sold on PrestaShop Addons by third parties.

Where this came from

“Production ready images with minimal tooling. Development and testing friendly images with tooling: PrestaShop Flashlight.”

  • PrestaShop publishes two Docker lines and says so: prestashop/prestashop is the minimal-tooling one it points at real deployments, and PrestaShop Flashlight is the development and testing one. source
  • With PS_INSTALL_AUTO set, the image's entrypoint runs PrestaShop's console installer and then deletes the install directory itself, and PS_FOLDER_ADMIN renames the admin directory before that installer runs, so both halves of the classic hardening happen without a browser wizard on a public address. source
  • The image ships demo@prestashop.com and prestashop_demo as the ADMIN_MAIL and ADMIN_PASSWD defaults, which is why this install overrides both with values generated on the server. source
  • PrestaShop's installer writes app/config/parameters.php with the database credentials and the cookie keys, and that same file is what the entrypoint looks for to decide whether the shop is already installed. source
  • Upstream asks for a memory_limit of at least 512M and MySQL 5.7 or MariaDB 10.2 as a floor, and its installer defaults the fixtures flag to 1, which is why a fresh shop arrives holding a demo catalogue. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host Shopify?

    Not Shopify itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is PrestaShop. A full storefront, catalogue and order pipeline on a domain you own, with no monthly platform fee sitting on top of the card rates. The install is one evening: 2 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 120 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces Shopify?

    PrestaShop. A full storefront, catalogue and order pipeline on a domain you own, with no monthly platform fee sitting on top of the card rates. The closest thing to Shopify's shape that you can run yourself: a catalogue, a cart, a checkout, taxes, shipping rules and a back office where orders arrive, all in one application that installs from one compose file. The official image runs PrestaShop's own console installer, deletes the setup wizard afterwards and renames the back office off its guessable path, so the two hardening steps a PHP shop is usually blamed for forgetting happen before you ever see a login page. What it does not replace is the half of the bill that was payments: you still sign a processor agreement, you still pay per transaction, and the uptime during a sale is now a thing you own. PrestaShop is OSL-3.0-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Shopify?

    2048 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. PrestaShop itself is free and OSL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Shopify Basic, $39/mo — $468 a year.

  • How hard is it really?

    ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the PrestaShop install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run PrestaShop on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same PrestaShop on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Every product link and checkout page this builds begins with http://localhost:8138, which resolves on this computer and nowhere else, so it is a shop you can build in and nobody can buy from. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.