🌐 Language: 🇻🇳 Tiếng Việt · 🇬🇧 English (current)
🐳 S-Cart with Docker
Run S-Cart (GP247 / Laravel 13) with Docker — on your own machine (dev) and on a real server (prod)
Introduction
This guide sets up S-Cart with Docker from scratch, for anyone who wants to try it on a local machine or deploy it to a server without installing PHP, Composer or MySQL themselves. After reading it you will be able to run both the dev and prod environments, know the everyday commands, and fix the common problems on your own.
Table of contents
- Requirements
- What's in the stack
- Run the DEV environment
- Run the PROD environment
- Everyday commands
- When to rebuild / restart
- Where your data lives
- The built-in MySQL
- Run several projects on one host
- Conditions & rules
- Q&A
🧰 Requirements
| Component | Requirement |
|---|---|
| Docker | Docker Desktop (Windows/Mac) or Docker Engine + Compose plugin (Linux) |
| Source code | This repo on your machine/server: git clone https://github.com/gp247net/s-cart.git |
| Windows | Use WSL2; for the best speed, keep the project inside the WSL2 filesystem (e.g. ~/projects/s-cart) rather than /mnt/c/... |
🧱 What's in the stack
| Service | Role | Image |
|---|---|---|
app |
PHP-FPM running Laravel | built from docker/php |
webserver |
Nginx, serves public/, forwards .php to app |
nginx:1.27-alpine |
queue |
php artisan queue:work (e-mail, background jobs) |
shares the app image |
scheduler |
Calls php artisan schedule:run every 60 seconds |
shares the app image |
mysql-local |
MySQL 8.4 (optional, enabled with COMPOSE_PROFILES=db-local) |
mysql:8.4 |
node |
Builds/serves assets (Vite) — runs only when needed | node:22-alpine |
There are two completely separate compose files — always use the right one for the environment:
docker-compose.yml — DEV |
docker-compose.prod.yml — PROD |
|
|---|---|---|
| Used on | Your local machine | A real server |
| Command | docker compose ... |
docker compose -f docker-compose.prod.yml ... |
| Default web port | 8000 |
80 |
APP_ENV / APP_DEBUG |
local / true (pinned in the compose file) |
production / false (pinned in the compose file) |
app runs as |
root (avoids bind-mount permission errors) | www-data from SC_DOCKER_WWWUSER/SC_DOCKER_WWWGROUP |
| Xdebug, Vite hot-reload | Yes | No |
| Container names | scart-app, scart-nginx… |
scart-app-prod, scart-nginx-prod… |
💻 Run the DEV environment
-
In the project folder, create the config file:
cp .env.example .env.env.examplealready works out of the box (it uses the MySQL bundled in Docker) — nothing to edit. -
Start all containers (the first run takes a few minutes to build the image):
docker compose up -d --buildCheck with
docker compose ps— the services should berunning. -
Install S-Cart:
docker compose exec app php artisan key:generate docker compose exec app php artisan gp247:install docker compose exec app php artisan gp247:shop-sample # optional: sample datagp247:installasks for confirmation — typeyes. -
Open your browser:
Page Address Storefront http://localhost:8000 Admin http://localhost:8000/gp247_admin — admin/adminVite dev server (asset hot-reload) http://localhost:5173 🔑 Change the
adminpassword right after your first login.
💡 To change ports or the database, edit
.envbefore step 2:SC_DOCKER_APP_PORT,SC_DOCKER_DB_PORT,DB_*,COMPOSE_PROFILES. Each variable is explained right in the#========DOCKER=========section of.env.example. SetAPP_URL=http://localhost:8000so links in e-mails (generated by the queue/CLI) carry the right port.
🏭 Run the PROD environment
-
On the server, create
.envfrom the sample:cp .env.example .env -
Pick one of two database options:
A. Remote / managed database (RDS, Cloud SQL…) B. MySQL bundled in Docker DB_HOSTyour-remote-mysql-hostmysql-localCOMPOSE_PROFILES(empty) — no mysql-localcontainer is createddb-localSC_DOCKER_DB_ROOT_PASSWORDnot needed required, change it from change_me_root -
Edit
.env— at least these lines (example for option A):APP_ENV=production APP_DEBUG=false DB_CONNECTION=mysql DB_HOST=your-remote-mysql-host DB_PORT=3306 DB_DATABASE=scart-db-prod DB_USERNAME=scart-user DB_PASSWORD=your_real_password # MUST change from the sample value "password" COMPOSE_PROFILES= SC_DOCKER_WWWUSER=1000 # run `id -u` on the server SC_DOCKER_WWWGROUP=1000 # run `id -g` on the serverDon't set
SC_DOCKER_APP_PORTunless you need a port other than80(e.g. behind a reverse proxy) — the prod compose file defaults to80. -
Start the containers:
docker compose -f docker-compose.prod.yml up -d --build -
Install S-Cart (first time only):
docker compose -f docker-compose.prod.yml exec app php artisan key:generate docker compose -f docker-compose.prod.yml exec app php artisan gp247:install -
Build the CSS/JS assets (they are not baked into the image — run again whenever CSS/JS changes):
docker compose -f docker-compose.prod.yml run --rm node -
Open
http://your-domainandhttp://your-domain/gp247_adminto check.
⚠️ Every prod command includes
-f docker-compose.prod.yml. Copy the commands as-is, don't type them from memory — without-f, Docker silently uses the DEV file. See Q7.
Shortcut — only valid for the current terminal session:
export COMPOSE_FILE=docker-compose.prod.yml # bash/zsh
# $env:COMPOSE_FILE = "docker-compose.prod.yml" # PowerShell
This variable is gone in a new SSH login, a new tab, or in cron/deploy scripts. In scripts and CI, always write the full -f docker-compose.prod.yml.
🔁 Everyday commands
The commands below are written for DEV. On prod, add
-f docker-compose.prod.ymlafterdocker compose. Not sure which file your shell targets? Rundocker compose config --services.
Update the code after git pull:
git pull
docker compose exec app composer install --no-interaction --optimize-autoloader # if composer.lock changed
docker compose exec app php artisan gp247:update # if gp247/* packages moved to a new version
docker compose exec app php artisan migrate --force # if there are new migrations
docker compose run --rm node # if assets changed
docker compose exec app php artisan config:cache
Ordinary code changes need no rebuild or restart — see section 6.
Other commands:
| Task | Command |
|---|---|
| Run any artisan command | docker compose exec app php artisan <command> |
| PHP / Nginx logs | docker compose logs -f app · docker compose logs -f webserver |
| Laravel log | tail -f storage/logs/laravel.log (PowerShell: Get-Content storage\logs\laravel.log -Wait -Tail 50) |
| Shell into the container | docker compose exec app sh |
| Stop everything (data is kept) | docker compose down |
| Back up the built-in MySQL | docker compose exec mysql-local mysqldump -u root -p"$SC_DOCKER_DB_ROOT_PASSWORD" scart > backup.sql |
| Update PHP dependencies | docker compose exec app composer update --no-interaction --optimize-autoloader, then docker compose restart queue scheduler |
💡 Set
LOG_STACK=dailyin.envto rotate the Laravel log daily instead of letting onelaravel.loggrow forever.
🔨 When to rebuild / restart
Rule of thumb: files copied into the image (by docker/php/Dockerfile) → rebuild after editing. Files that are only mounted → just restart/recreate. Application code → nothing.
| Change | Rebuild? | Restart? | Command |
|---|---|---|---|
docker/php/Dockerfile, php.ini, entrypoint.sh, prod-guard.sh |
Yes | Yes | docker compose build app && docker compose up -d |
.env: SC_DOCKER_PHP_VERSION, SC_DOCKER_WWWUSER, SC_DOCKER_WWWGROUP |
Yes | Yes | docker compose up -d --build |
.env: runtime variables (DB_HOST, SC_DOCKER_APP_PORT, SC_DOCKER_DB_PORT, SC_DOCKER_XDEBUG_MODE…) |
No | Yes | docker compose up -d |
docker-compose*.yml |
No | Yes | docker compose up -d |
docker/nginx/default.conf |
No | Yes | docker compose restart webserver |
docker/mysql/my.cnf |
No | Yes | docker compose restart mysql-local |
| PHP / Blade code | No | No | — (re-run config:cache if you use it) |
composer.json / composer.lock |
No | No | docker compose exec app composer install ... |
package.json / JS-CSS assets |
No | No | docker compose run --rm node |
APP_ENV / APP_DEBUG in .env |
No | No | No effect inside the containers — the compose file pins both for app, queue and scheduler. To change them, edit the compose file |
Ran
php artisan config:cachebefore?.envchanges only take effect after you runconfig:cache(orconfig:clear) again.
💾 Where your data lives
The image contains no code — the whole project folder is bind-mounted from your machine into the container (./:/var/www/html). So rebuilding or recreating containers does not lose these folders:
app/GP247— the controllers, helpers, plugins and templates you customizedpublic/GP247,public/vendor,resources/views/vendorstorage/app/public— product images, uploaded files
The exceptions live in Docker named volumes (much faster than a bind-mount on Windows):
| Content | DEV volume | PROD volume |
|---|---|---|
vendor/ |
scart_scart-vendor |
scart-vendor |
node_modules/ |
scart_scart-node-modules |
scart-node-modules |
| MySQL data | scart_scart-mysql-local-data-dev |
scart-mysql-local-data-prod |
- Volumes survive
docker compose down; they are only lost withdocker compose down -vordocker volume rm. - You can't browse
vendor/andnode_modules/from your file explorer — that's expected, you shouldn't edit them by hand. Losing these volumes is harmless: the container reinstalls them on start. - See the real volume names with
docker volume ls. PROD names are pinned indocker-compose.prod.yml; DEV names carry thescart_project prefix.
⚠️ Upgrading a prod server that ran an older version? Run
docker volume lsbefore pulling the newdocker-compose.prod.yml. If the MySQL volume is named likescart_scart-mysql-local-data-prod(with a prefix), rename/copy it toscart-mysql-local-data-prod, or editname:in the compose file to match — otherwise MySQL starts on a new, empty volume.
When moving to another server, take the folders listed above with you (via git, rsync, or a separate backup).
🐬 The built-in MySQL
With COMPOSE_PROFILES=db-local, the mysql-local container creates the database and user from .env:
.env variable |
Becomes | DEV default (if unset) |
|---|---|---|
DB_DATABASE |
MYSQL_DATABASE |
scart |
DB_USERNAME |
MYSQL_USER |
scart |
DB_PASSWORD |
MYSQL_PASSWORD |
scart |
SC_DOCKER_DB_ROOT_PASSWORD |
MYSQL_ROOT_PASSWORD |
root_secret |
- PROD has no defaults — all 4 variables must be set.
- Laravel reads the same
.env, so the values always match; just useDB_HOST=mysql-local(the service name, not a real hostname). - Using HeidiSQL/DBeaver on your machine (DEV only): connect to
127.0.0.1, portSC_DOCKER_DB_PORT(default3306).
List the databases inside the container:
docker compose exec mysql-local mysql -u root -p"$SC_DOCKER_DB_ROOT_PASSWORD" -e "SHOW DATABASES;"
Add a missing database/user without touching existing data:
docker compose exec mysql-local mysql -u root -p"$SC_DOCKER_DB_ROOT_PASSWORD" -e "CREATE DATABASE IF NOT EXISTS your_db; CREATE USER IF NOT EXISTS 'your_user'@'%' IDENTIFIED BY 'your_pass'; GRANT ALL ON your_db.* TO 'your_user'@'%';"
Full reset — ⚠️ deletes all MySQL data, cannot be undone, back up first:
docker compose down
docker volume ls | grep mysql-local-data # check the real name before deleting
docker volume rm scart_scart-mysql-local-data-dev # DEV; PROD: scart-mysql-local-data-prod
docker compose up -d
🏢 Run several projects on one host
Docker tells stacks apart by project name, not by folder. Every container name, image tag, volume and network is derived from one variable, SC_DOCKER_INSTANCE (unset = scart). So every project needs its own SC_DOCKER_INSTANCE — two folders that both leave it empty are the same stack to Docker: up in the second folder takes over the first project's containers and its MySQL database, with no warning at all.
To add a 2nd, 3rd… project:
-
Put each project in its own folder.
-
In that project's
.env, set its own instance name and host ports:SC_DOCKER_INSTANCE=shopa # unique per project: shopa, shopb… SC_DOCKER_APP_PORT=8001 # a different web port per project SC_DOCKER_VITE_PORT=5174 # DEV only SC_DOCKER_DB_PORT=3307 # DEV only, with COMPOSE_PROFILES=db-local # KEEP THE SAME in every project: DB_HOST=mysql-local DB_PORT=3306 -
Start as usual (
docker compose up -d --build, add-f docker-compose.prod.ymlfor prod).
Result with SC_DOCKER_INSTANCE=shopa:
| Identifier | Default (unset) | SC_DOCKER_INSTANCE=shopa |
|---|---|---|
| Project (dev / prod) | scart / scart-prod |
shopa / shopa-prod |
| Containers | scart-app, scart-nginx… |
shopa-app, shopa-nginx… |
| Image tag (dev / prod) | scart-app:8.3 / scart-app:8.3-prod |
scart-app:8.3-shopa / scart-app:8.3-shopa-prod |
| DEV volumes | scart_scart-vendor, scart_scart-mysql-local-data-dev… |
shopa_scart-vendor, shopa_scart-mysql-local-data-dev… |
| PROD volumes | scart-vendor, scart-mysql-local-data-prod… |
shopa-vendor, shopa-mysql-local-data-prod… |
| Network (dev / prod) | scart_scart / scart-prod_scart |
shopa_scart / shopa-prod_scart |
What each variable does when several stacks share a host:
| Variable | Effect | With several stacks |
|---|---|---|
SC_DOCKER_INSTANCE |
Name of the project, containers, image tag, volumes, network | Must differ |
SC_DOCKER_APP_PORT |
Web port on the host (default dev 8000, prod 80) |
Must differ, or put a reverse proxy in front |
SC_DOCKER_VITE_PORT |
Vite dev server port (DEV only) | Must differ when running several dev stacks |
SC_DOCKER_DB_PORT |
Host port of mysql-local for DB tools (DEV only) |
Must differ when several dev stacks enable db-local |
DB_HOST / DB_PORT |
Where Laravel connects, inside the Docker network | Keep mysql-local / 3306 — each stack has its own network |
DB_DATABASE / DB_USERNAME / DB_PASSWORD |
The project's database | May repeat if each stack has its own mysql-local; must differ when sharing one MySQL |
COMPOSE_PROFILES |
db-local = own MySQL; empty = external DB |
Leave empty to let several stacks share one MySQL (saves RAM) |
SC_DOCKER_PHP_VERSION |
PHP version of the image | May differ |
SC_DOCKER_WWWUSER / SC_DOCKER_WWWGROUP |
UID/GID of www-data (PROD) |
Match the owner of each project's folder |
- Several sites in prod → use a reverse proxy (Traefik, Caddy, or Nginx on the host) that publishes
80/443and routes by domain to each stack; the stacks then don't publish ports themselves, and TLS is handled in one place. - Resources: each instance is 4–6 containers, so RAM/CPU grows with the instance count. To fit more sites on a small VPS, point the instances at one shared MySQL (one
DB_DATABASEper site, emptyCOMPOSE_PROFILES=).
🚦 Conditions & rules (know before you act)
When starting PROD
- A prod container refuses to start while sample secrets remain — with
APP_ENV=production,DB_PASSWORDmust not bepassword, and withCOMPOSE_PROFILES=db-local,SC_DOCKER_DB_ROOT_PASSWORDmust not bechange_me_root. A live site running on a password everyone knows is a site waiting to be taken over. The check compares against the sample values only — it never judges the "strength" of a real password, and never blocks DEV. - Prod commands must include
-f docker-compose.prod.yml— without it Docker doesn't fail, it uses the DEV file (debug on, running as root, Xdebug installed). APP_ENV/APP_DEBUGare pinned in the compose file — editing.envdoesn't change them inside the containers; this guarantees prod can never run in debug mode by accident..envstill needs both lines for commands run outside the containers.
When configuring the database
- The built-in MySQL creates the database/user/root password only on its very first start (empty data volume). Changing
DB_*orSC_DOCKER_DB_ROOT_PASSWORDin.envafterwards does not change MySQL — change it inside MySQL (CREATE USER,ALTER USER) or reset the volume. - Don't change
DB_PORTto avoid port clashes between projects —DB_PORTis the port inside the Docker network, where MySQL always listens on3306. The host-side port isSC_DOCKER_DB_PORT. (Older instructions saidDB_PORT=3307for a second project — if you followed them: setDB_PORT=3306back, move3307toSC_DOCKER_DB_PORT, thendocker compose up -d.)
When setting SC_DOCKER_INSTANCE
- Lowercase letters, digits,
-and_only, starting with a letter or digit — Docker's project-name rule. - Choose it before the first
up. To change it later:docker compose downfirst, then edit.envandup -d. The new stack starts on new, empty volumes — the old data isn't lost, it stays in the volumes with the old name.
When re-running gp247:install
- The installer overwrites store data and some published files. Back up the database and
app/GP247first if the site already has data or customizations.
❓ Q&A
Q1: Do I have to rebuild the image after git pull?
→ No. The code is bind-mounted into the container, so it takes effect immediately. Rebuild only when you edit files in docker/php/ — see section 6.
Q2: Will rebuilding or removing containers lose my customizations and uploaded images?
→ No. app/GP247, public/GP247, storage/app/public… live on your disk. Only docker compose down -v or docker volume rm deletes MySQL data — see section 7.
Q3: composer install fails with "process timeout" on the first run?
→ Common on Windows when the project lives under /mnt/c/... or /mnt/d/.... Re-run docker compose exec app composer install --no-interaction --optimize-autoloader — it only has to finish once. For a real speed-up, move the project into the WSL2 filesystem (e.g. ~/projects/s-cart).
Q4: On Linux, files created by the container belong to root and need sudo to edit?
→ DEV runs app as root to avoid permission errors. To run it as your user, in the app service of docker-compose.yml set build.args.WWWUSER/WWWGROUP to your id -u/id -g and environment.PHP_FPM_ALLOW_ROOT: "false", then docker compose build app && docker compose up -d. ⚠️ On Windows with the project under /mnt/..., this causes touch(): Utime failed — keep the root default, or move the project into WSL2.
Q5: I deploy prod as root and get Permission denied writing composer.lock / vendor/?
→ www-data in the container runs as UID SC_DOCKER_WWWUSER (default 1000), so it can't write root-owned files. Grant access once with ACLs (replace 1000 if you use another UID):
apt-get install -y acl
cd /path/to/project
setfacl -R -m u:1000:rwx .
setfacl -R -d -m u:1000:rwx .
Or dedicate a UID 1000 user to deployments and run chown -R 1000:1000 /path/to/project.
Q6: A prod container says [prod-guard] Refusing to start?
→ .env still holds a sample password. See which variable with docker compose -f docker-compose.prod.yml logs app, set a real password (or COMPOSE_PROFILES= if you use a remote DB), then docker compose -f docker-compose.prod.yml up -d. If the MySQL volume was already created with the sample password, also change it inside MySQL with ALTER USER.
Q7: I ran docker compose up -d --build on prod and forgot -f docker-compose.prod.yml?
→ The prod stack is not affected — Docker just starts an extra, separate DEV stack (names without -prod, its own volumes). You may see a port-conflict error rather than anything being overwritten. Stop the accidental DEV stack with docker compose -f docker-compose.yml down, then check prod with docker compose -f docker-compose.prod.yml ps.
Q8: I changed DB_DATABASE/the password in .env but MySQL didn't change?
→ MySQL reads these values only on its first start. Add the database/user with the command in section 8, or reset the volume (loses data).
Q9: Where are the Nginx and PHP-FPM logs?
→ Inside the containers — view them with docker compose logs -f webserver and docker compose logs -f app. To get the Nginx log on your machine, add the volume ./storage/logs/nginx:/var/log/nginx to the webserver service in the compose file.
Q10: How do I use a remote database instead of the built-in MySQL?
→ In .env: DB_HOST=your-remote-mysql-host and COMPOSE_PROFILES= (empty), then docker compose up -d. No mysql-local container is created. See the table in step 2 of PROD.
📅 Last updated: 2026-09-29 · ✍️ Author: GP247