Docker & the production image
ShopClass publishes a self-contained image: Nginx (the web server), PHP-FPM (the process that runs the PHP code) and Supervisor (the tool that keeps both running), all in one container, with the Storefront theme baked in. It provisions itself on first boot.
docker pull ghcr.io/mindstellar/shopclass:latestTags are published per release, with :latest tracking the newest stable
release. The published image runs PHP 8.5.
One-command install
Section titled “One-command install”On a Linux server, one command sets up a site with its own database. If Docker is missing, it offers to install it with Docker’s official script:
curl -fsSL https://github.com/mindstellar/shopclass/releases/latest/download/install.sh | shIt asks for a domain and an admin e-mail, writes a stack into ./shopclass, starts
it, and prints the admin password. With a domain, the site gets a free HTTPS
certificate, see built-in HTTPS. The domain must point at the
server, and ports 80 and 443 must be open. It can also send www. to the bare
domain, or the other way round.
To skip the questions:
curl -fsSL https://github.com/mindstellar/shopclass/releases/latest/download/install.sh \Run sh install.sh --help for every option. To read the script before you run it,
download it, check it against install.sh.sha256 on the same release, then run it.
Upgrade: run the command again in the same place. It moves the stack to the new
release, and keeps .env and the passwords in it. Settings such as mail (SMTP_*)
are in shopclass/.env; after you change them, run docker compose up -d in that
folder.
Bringing it up
Section titled “Bringing it up”docker-compose.prod.yml in the repository brings up the image with a database:
docker compose -f docker-compose.prod.yml up -dIt comes up already installed at http://localhost:8080, admin at
/oc-admin/. There is no installer to click through: the container installs
itself from the environment when it first starts.
Configuration
Section titled “Configuration”Everything is set from environment variables:
| Variable | Purpose |
|---|---|
DB_HOST / DB_NAME / DB_USER / DB_PASSWORD |
Database connection |
WEB_PATH |
The site’s public base URL |
OSC_CLI_URL |
The site’s address for oc-cli.php only, when WEB_PATH is left unset so web pages keep the address they were opened on. Use the exact address visitors use |
OSC_ADMIN_USER / OSC_ADMIN_EMAIL / OSC_ADMIN_PASSWORD |
The first admin account. Leave the password unset and a strong one is generated and printed to the logs |
OSC_SITE_TITLE |
Site title at provisioning time |
OSC_IGNORE_CONFIG_FILE |
Set to 1 so the image configures itself from the environment rather than a config.php |
OSC_DISABLE_WEB_RESTORE |
Set to 1 to turn off restoring backups from the admin. Backups still work: see backups |
OSC_DISABLE_PACKAGE_INSTALLS |
Set to 1 to turn off installing and updating plugins and themes from the admin market and oc-cli.php market:* |
OSC_REAL_IP_HEADER / OSC_REAL_IP_TRUSTED |
The header carrying the real client IP behind a proxy, e.g. X-Real-IP or CF-Connecting-IP, and the address ranges to trust it from (in CIDR notation, e.g. 172.16.0.0/12): see putting it behind TLS |
OSC_CACHE / OSC_CACHE_HOST / OSC_CACHE_PORT |
Object cache |
OSC_MICROCACHE |
Set to 1 to cache public pages in nginx: see page caching. The image already carries the purge module (lets a cached page be removed early), so the nginx Cache plugin works with nothing further to configure |
OSC_RATE_LIMIT / OSC_RATE_LIMIT_BURST |
Requests per second per client IP, e.g. 10r/s. Unset is off |
OSC_TLS_DOMAIN / OSC_TLS_REDIRECT_FROM / OSC_TLS_EMAIL |
Built-in HTTPS: the domain, other names to send to it (comma-separated), and the e-mail for expiry notices (default OSC_ADMIN_EMAIL) |
For a real deployment: point DB_HOST at a managed database, set WEB_PATH to
the public URL, set a strong admin password, and
offload uploads to S3 so more than one instance
can run.
Volumes
Section titled “Volumes”Four paths must survive a redeploy:
| Path | Holds |
|---|---|
oc-content/uploads |
Listing photos |
oc-content/downloads |
Files served by download-type plugins |
oc-content/plugins |
Plugins installed through the market |
oc-content/themes |
Themes installed through the market |
The last two matter more than they look. Without them, a package installed from the admin lives in the container’s writable layer and is discarded on the next redeploy.
Core and packages update differently
Section titled “Core and packages update differently”This is the part that surprises people.
Core ships baked into the image. A core update is a redeploy with a newer
image tag. The container migrates its own schema on start, and the in-app core
updater is switched off (OSC_DISABLE_SELF_UPDATE=1), otherwise it would write
over itself, only to lose the write on the next redeploy.
Plugins and themes live in volumes. A package installed or updated through
the admin market, or through oc-cli.php market:install / market:update,
survives a redeploy.
On every start, the entrypoint (the script the container runs on startup) compares the volume against the packages baked into the new image: it installs any that are missing and refreshes any the image ships a newer version of, without ever touching a package installed through the market. You can run that step yourself:
docker compose exec app php oc-cli.php package:reconcileEdge builds
Section titled “Edge builds”:edge is built from the develop branch: the next release, before it is
released. It is rebuilt each night when develop has a new commit that passed
its tests, and sometimes in between. There is no zip and no GitHub release for
it, and the in-app updater stays off.
Its version is the develop version plus the build time in UTC, for example
6.4.0.202610030200 or 6.4.0.rc6.202610022159. Tools → System info and
php oc-cli.php version show it with the commit, for example
6.4.0.202610030200 (edge, a65dcfe).
To switch, set the image to ghcr.io/mindstellar/shopclass:edge and redeploy.
To update, pull :edge again and redeploy; the database upgrade runs on start.
Running commands
Section titled “Running commands”Every CLI command works inside the container:
docker compose exec app php oc-cli.php doctordocker compose exec app php oc-cli.php crondocker compose exec app php oc-cli.php user:reset-password --user=adminCron in a container
Section titled “Cron in a container”The container does not schedule anything for you. Run cron from the host, from a sidecar (a small helper container running next to the app), or from your orchestrator (the system managing your containers, such as Kubernetes):
*/5 * * * * docker compose -f /path/to/docker-compose.prod.yml exec -T app php oc-cli.php cronOn Kubernetes, a CronJob running the same command is the equivalent. Without
it, alerts never send and listings never expire. See
setting up cron.
Built-in HTTPS
Section titled “Built-in HTTPS”For a single server, the image can serve HTTPS itself. Set the domain, publish ports 80 and 443, and keep the certificate on a volume:
services: app: ports: - "80:80" - "443:443" environment: WEB_PATH: https://example.com/ OSC_TLS_DOMAIN: example.com OSC_TLS_REDIRECT_FROM: www.example.com volumes: - tls:/var/lib/shopclass-tlsOn first start the site answers on port 80 while acme.sh
gets a Let’s Encrypt certificate. Then port 80 sends visitors to HTTPS, and
OSC_TLS_REDIRECT_FROM names are sent to the domain. The certificate renews by
itself 30 days before it runs out. If a redirect name does not point at the server
yet, the domain still gets HTTPS and the log says which name failed. Follow it with
docker compose logs -f app | grep tls:.
Without the tls volume, every new container asks for a new certificate, and Let’s
Encrypt limits how many you can get in a week.
Putting it behind TLS
Section titled “Putting it behind TLS”With more than one instance, or a proxy you already run, leave OSC_TLS_DOMAIN
unset. The image then speaks plain HTTP on port 80 and sits behind something that
terminates TLS. Terminate on the host with nginx, and let certbot own the
certificate.
Start by taking the container off the public interface, so the only way in is through the proxy:
services: app: ports: - "127.0.0.1:8080:80" # loopback onlyThen a server block on the host. This is the plain-HTTP form: certbot rewrites it in the next step:
server { listen 80; server_name example.com;
# Must be at least as large as the app's own limit, or uploads fail at the # proxy with a 413 before ShopClass ever sees them. client_max_body_size 108M;
location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }}Then get the certificate:
certbot --nginx -d example.comThat obtains it, rewrites the block to listen on 443, and adds the redirect from port 80.
How renewal is handled
Section titled “How renewal is handled”Nothing about renewal touches the container. The certificate lives on the host, the host’s nginx is what serves it, and certbot’s nginx installer reloads that nginx after a successful renewal. There is no mounted certificate, no deploy hook to write, and no container restart in the loop. That is the main reason to terminate here rather than inside the image.
Installing certbot from your distribution’s package (or snap) also installs the renewal job, so this is already running:
systemctl list-timers | grep certbot # twice-daily checkcertbot renew --dry-run # prove the whole path workscertbot renew does nothing until a certificate is within 30 days of expiry, so
running it often is free and expected. Two things keep it working:
- Leave port 80 open on the host. The HTTP-01 challenge (Let’s Encrypt’s way of confirming you control the domain) arrives there. The redirect certbot adds is fine, since Let’s Encrypt follows it, but a firewall that drops :80 entirely will fail every renewal, silently, until the certificate expires.
- Do not hand-edit the
managed by Certbotlines in the server block. That is how certbot finds what to update.
Run the dry run once after setup. If it passes, renewal is genuinely unattended.
What the app needs to be told
Section titled “What the app needs to be told”TLS is invisible to ShopClass unless these are set:
| Setting | Value |
|---|---|
WEB_PATH |
https://example.com/: the app builds every URL and cookie path from this |
OSC_REAL_IP_HEADER |
X-Real-IP, matching the proxy_set_header above |
OSC_REAL_IP_TRUSTED |
172.16.0.0/12: see below |
X-Forwarded-Proto is what makes the app treat the request as secure: it sets
HTTPS=on for PHP, so osc_is_ssl() is true and the login cookie is issued with
the Secure flag. Without it, a visitor on HTTPS gets cookies that are not
marked secure, and the app generates http:// links.
OSC_REAL_IP_TRUSTED is the one people get wrong. When the host proxies into a
published port, the container does not see 127.0.0.1. It sees the Docker
bridge gateway (the address Docker’s internal network uses to reach the host),
something like 172.19.0.1. Trusting loopback there restores nothing, and every
visitor arrives as the gateway, which collapses login throttling and abuse-report
keying onto a single identity. 172.16.0.0/12 covers Docker’s default pools;
narrow it to your own gateway with docker network inspect.
Other terminators
Section titled “Other terminators”An ALB (a cloud load balancer), a Kubernetes ingress (the routing rules for a
Kubernetes cluster), Cloudflare or a managed platform all work the same way:
the contract is the three settings above plus a proxy that sends
X-Forwarded-Proto. Only the certificate’s owner changes. See
security and the
caching contract.
With page caching on
Section titled “With page caching on”Nothing changes. The container’s cache key does not include the scheme, so the
nginx Cache plugin’s purge endpoint stays http://127.0.0.1/purge, with or without
built-in HTTPS. The
plugin’s host list is the public hostname, because that is the Host
visitors send and therefore what the cache is keyed on. See
page caching.
Running more than one instance
Section titled “Running more than one instance”Three things have to be true before a second instance is safe:
- Uploads are offloaded to S3, otherwise each instance has its own photos.
- The object cache is memcached, not APCu: APCu lives inside one PHP process, so two instances never see the same cache.
- Cron runs once, not once per instance.
Local development
Section titled “Local development”For working on ShopClass itself there is a separate development stack (PHP-FPM,
MariaDB, Nginx, Memcached, Mailhog and phpMyAdmin) in docker-compose.dev.yml.
See the developer documentation.