Opencart 4 tutorial · 8 min read · Updated

Git, CI/CD and Staging for OpenCart: A Deployment Workflow That Doesn’t Break the Live Store

Stop editing OpenCart over FTP. Put the store in Git with the right .gitignore, keep secrets out of config.php, run a staging copy, lint every push on several PHP versions with GitHub Actions, build extension zips automatically, and deploy with atomic releases and rollback.

Opencart git CI CD staging deployment

Most OpenCart stores are changed the same way: someone connects with FTP, edits a file on the live server, and refreshes the page to see if it worked. When it doesn’t, the store is broken until they fix it, and nobody knows exactly what changed last month when the checkout started acting strangely.

A small amount of process fixes this: Git for history, a staging copy for testing, automatic checks on every change, and a deployment that can be undone in seconds. This guide sets that up for an OpenCart 4 store, using GitHub and GitHub Actions. GitLab and Bitbucket work the same way.

What goes in Git, and what doesn’t

An OpenCart store mixes three kinds of files:

KindExamplesIn Git?
Codeadmin/, catalog/, system/ (except storage), extension/, your themeYes
Configuration with secretsconfig.php, admin/config.phpNo: generated per server
Dataimage/catalog/ (uploaded product images), storage/ (cache, logs, sessions, downloads), the databaseNo: backed up separately

A .gitignore for an OpenCart 4 store:

# Per-server configuration
/config.php
/admin/config.php

# Data, cache and uploads
/image/cache/
/image/catalog/
/system/storage/
/storage/

# Never deploy the installer
/install/

# Local tools
.DS_Store
.idea/
.vscode/
node_modules/
vendor/

If you renamed the admin folder, adjust the second line. If the theme uses images you manage as code (icons, backgrounds), keep them in the theme folder, not in image/catalog/.

Start the repository from the exact OpenCart release you run, as the first commit. Every later commit then shows your changes, and an OpenCart upgrade becomes a diff you can read.

Keep secrets out of the code

Database passwords and API keys don’t belong in Git, even in a private repository. Repositories get shared with freelancers, copied to laptops and occasionally made public by mistake. The Twelve-Factor App methodology has a simple test for this: whether “the codebase could be made open source at any moment, without compromising any credentials.”

The twelve-factor app stores config in environment variables

The Twelve-Factor App: Config

OpenCart’s config.php is a PHP file, so it can read environment variables. Keep a template in Git (config.example.php) and let each server have its own config.php:

<?php
// config.php (not in Git). Values come from the server's environment.
define('APPLICATION', 'Catalog');

define('HTTP_SERVER', getenv('OC_HTTP_SERVER') ?: 'https://example.com/');

define('DIR_OPENCART', __DIR__ . '/');
define('DIR_APPLICATION', DIR_OPENCART . 'catalog/');
define('DIR_EXTENSION', DIR_OPENCART . 'extension/');
define('DIR_IMAGE', DIR_OPENCART . 'image/');
define('DIR_SYSTEM', DIR_OPENCART . 'system/');
define('DIR_STORAGE', getenv('OC_DIR_STORAGE') ?: DIR_SYSTEM . 'storage/');
// ... the remaining DIR_ lines exactly as your installer wrote them ...

define('DB_DRIVER', 'mysqli');
define('DB_HOSTNAME', getenv('OC_DB_HOSTNAME'));
define('DB_USERNAME', getenv('OC_DB_USERNAME'));
define('DB_PASSWORD', getenv('OC_DB_PASSWORD'));
define('DB_DATABASE', getenv('OC_DB_DATABASE'));
define('DB_PORT', getenv('OC_DB_PORT') ?: '3306');
define('DB_PREFIX', 'oc_');

Set the variables in PHP-FPM’s pool file (env[OC_DB_PASSWORD] = ...) or your host’s control panel. Copy the exact list of DIR_ constants from the config.php your OpenCart installer created, because they differ slightly between versions.

Payment and other extension API keys are stored in OpenCart’s database settings, so they’re already outside the code.

A staging store

Staging is a second copy of the store where changes are tested before they reach customers:

  1. A subdomain such as staging.example.com, with HTTP basic authentication (or Cloudflare Access) in front, so search engines and customers can’t reach it.
  2. Its own database, copied from production. Anonymize customer data in the copy (emails, addresses, phone numbers), or at least make sure staging can’t email real customers:

– Set the mail engine to something that doesn’t deliver (System > Settings > Mail), or point SMTP at a test inbox such as Mailpit. – Switch payment extensions to test or sandbox mode. – Turn off cron jobs that email customers, like abandoned cart reminders.

  1. Its own config.php and storage folder.

Refresh staging from production regularly (weekly, or before each big change). Our OpenCart migration guide covers copying a store to another URL.

The database problem

Code moves through Git. Settings don’t: installing an extension, changing a layout or adding a module writes to the database, and those changes don’t travel with a git push. Practical rules:

  • Extensions: install the same .ocmod.zip on staging and production. The zip is in Git (or built by CI, below), so the code is identical; repeat the install and configuration steps on production.
  • Settings and layouts: keep a short deployment note in the pull request (“after deploy: enable module X in Extensions > Modules, add it to the Product layout”). It’s low-tech and it works.
  • Schema changes in your own extensions: put CREATE TABLE IF NOT EXISTS and ALTER TABLE statements in the extension’s install() method and make them safe to run twice.

Continuous integration with GitHub Actions

Every push should be checked automatically before anyone deploys it. This workflow lints every PHP file on all PHP versions you care about, and fails the build if any file has a syntax error. Save it as .github/workflows/ci.yml:

name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  lint:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        php: ['8.2', '8.3', '8.4', '8.5']
    steps:
      - uses: actions/checkout@v4

      - uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          tools: none

      - name: Lint PHP files
        run: |
          errors=$(find . -name "*.php" -not -path "./system/storage/*" -print0 \
            | xargs -0 -n1 -P4 php -l 2>&1 | grep -v "^No syntax errors" || true)
          if [ -n "$errors" ]; then echo "$errors"; exit 1; fi

Once linting passes reliably, add static analysis for your own code (PHPStan at a low level on extension/your_extension/), and the PHP compatibility scan from our PHP 8 guide.

Build extension zips automatically

If you develop extensions, let CI build the .ocmod.zip that you install, so what you test is exactly what’s in Git. A job that zips the folder in the layout OpenCart’s installer expects and keeps it as a downloadable artifact:

  package:
    needs: lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build my_extension.ocmod.zip
        run: |
          cd extension/my_extension
          zip -r ../../my_extension.ocmod.zip install.json admin catalog $( [ -d system ] && echo system )
      - uses: actions/upload-artifact@v4
        with:
          name: my_extension
          path: my_extension.ocmod.zip

install.json must be at the root of the zip, next to the admin and catalog folders. Our OpenCart 4 extension tutorial explains the structure.

Deployment with atomic releases

The deployment problem with FTP is that for a few seconds, or minutes if it fails halfway, the server has half old files and half new files. The fix is to upload each release to its own folder and switch a symlink:

/var/www/store/
├── releases/
│   ├── 20261020-1410/
│   └── 20261026-0930/    ← new release
├── shared/
│   ├── config.php
│   ├── admin-config.php
│   ├── image/catalog/
│   └── storage/          (outside the web root, DIR_STORAGE points here)
└── current -> releases/20261026-0930

The web server’s root is /var/www/store/current. A deploy job in the same workflow, run only on main:

  deploy:
    needs: lint
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        env:
          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          HOST: ${{ secrets.DEPLOY_HOST }}
        run: |
          mkdir -p ~/.ssh && echo "$SSH_KEY" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
          ssh-keyscan -H "$HOST" >> ~/.ssh/known_hosts
          RELEASE=$(date +%Y%m%d-%H%M%S)
          rsync -az --exclude .git --exclude .github ./ deploy@$HOST:/var/www/store/releases/$RELEASE/
          ssh deploy@$HOST "
            cd /var/www/store/releases/$RELEASE &&
            ln -s /var/www/store/shared/config.php config.php &&
            ln -s /var/www/store/shared/admin-config.php admin/config.php &&
            ln -s /var/www/store/shared/image/catalog image/catalog &&
            ln -sfn /var/www/store/releases/$RELEASE /var/www/store/current &&
            sudo systemctl reload php8.3-fpm &&
            ls -1dt /var/www/store/releases/* | tail -n +6 | xargs rm -rf
          "

What this does: upload the new release next to the old one, link the shared configuration and uploaded images into it, switch current in one step, reload PHP-FPM so OPcache drops the old code (see OPcache settings for OpenCart), and keep the last five releases.

Rollback is switching the symlink back:

ln -sfn /var/www/store/releases/20261020-1410 /var/www/store/current && sudo systemctl reload php8.3-fpm

The environment: production line lets you require a manual approval in GitHub before the job runs (Settings > Environments > Required reviewers). For a store, that’s a good default.

After a deploy, clear OpenCart’s theme cache if templates changed (the gear icon on the admin dashboard), and refresh modifications if you use OCMOD.

Shared hosting without SSH

If your host only offers FTP or cPanel, you can still use Git and CI:

  • Many cPanel hosts have Git Version Control, which can pull from your repository and run a deploy script defined in .cpanel.yml.
  • Otherwise, CI can upload with an FTP deploy action. You lose atomic switching, so deploy at quiet times and keep the previous release in a folder to restore from.

A workflow that fits a small team

  1. Each change starts as a branch and a pull request, even if you’re the only developer.
  2. CI lints it. You deploy the branch to staging and test.
  3. The pull request description lists any admin steps needed after deploy.
  4. Merge to main. CI deploys to production after approval.
  5. If something’s wrong, roll back first and investigate second.

AI coding assistants fit into the same flow: they work in branches, and CI checks their output like anyone else’s. See our guide to AI coding tools for OpenCart extension development.

Rupak Nepali
Written byRupak Nepali

Author of four Opencart book.
The recent are Opencart 4 developer book and Opencart 4 user manual

Leave a Reply

Your email address will not be published. Required fields are marked *