Skip to content

Instantly share code, notes, and snippets.

@postadelmaga
Last active February 28, 2026 13:16
Show Gist options
  • Select an option

  • Save postadelmaga/36cc134b136a348834355274a35bcdf3 to your computer and use it in GitHub Desktop.

Select an option

Save postadelmaga/36cc134b136a348834355274a35bcdf3 to your computer and use it in GitHub Desktop.
OpenMage local development ( Docker / Alpine ) with FrankenPHP + MariaDB

OpenMage Local Development with FrankenPHP + MariaDB

A Docker setup for running OpenMage locally using FrankenPHP (Caddy + PHP in a single container) and MariaDB, with separate frontend and admin URLs.

Stack

  • FrankenPHP 1 / PHP 8.3 (Alpine) — includes Caddy with tls internal (local HTTPS) + Xdebug
  • MariaDB LTS
  • phpMyAdmin

Requirements

  • Docker + Docker Compose
  • Internet connection (required for nip.io DNS resolution)

Directory structure

openmage-docker/
├── Caddyfile
├── Dockerfile
├── docker-compose.yml
├── install.sh
├── .env               # your local config (not committed)
├── .env.example       # template to copy
└── src/               # OpenMage will be installed here

URLs

By default the setup uses nip.io — a public DNS service that resolves any domain containing an IP address back to that IP. No /etc/hosts edits required.

Service URL
Frontend https://openmage.127.0.0.1.nip.io
Admin https://openmage-admin.127.0.0.1.nip.io/admin
phpMyAdmin https://om-phpmyadmin.127.0.0.1.nip.io

You can override these in .env with your own domains (including .test domains if you prefer to manage /etc/hosts manually).


Quick Install

1. Configure the environment

cp .env.example .env

Edit .env with your preferred values — locale, timezone, currency, admin credentials, etc. The default URLs use nip.io and work out of the box.

2. Run the install script

chmod +x install.sh
./install.sh

The script will:

  • Build and start all containers
  • Install OpenMage via Composer
  • Wait for the database to be ready
  • Run the OpenMage installer with the values from .env
  • Configure the separate admin URL in the database
  • Flush the cache

To reset everything and start fresh:

./install.sh --reset

3. Trust the local CA certificate

FrankenPHP generates a local CA to sign the tls internal certificate. Import it once so your browser trusts it.

Extract the certificate:

docker cp openmage_app:/data/caddy/pki/authorities/local/root.crt ./caddy-root.crt

Arch Linux:

sudo cp caddy-root.crt /etc/ca-certificates/trust-source/anchors/caddy-root.crt
sudo trust extract-compat

Debian / Ubuntu:

sudo cp caddy-root.crt /usr/local/share/ca-certificates/caddy-root.crt
sudo update-ca-certificates

Chrome / Chromium: go to chrome://settings/certificatesAuthoritiesImport → select caddy-root.crt → check "Trust this certificate for identifying websites".

Firefox: Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import → select caddy-root.crt.

Restart your browser after importing.

Note: If you delete the caddy_data Docker volume, a new certificate will be generated and you will need to re-import it.

4. Open the store

Frontend and admin URLs are printed at the end of the install script.


Manual Install

If you prefer to install OpenMage via the web wizard:

mkdir src
docker compose up -d
docker compose run --rm app composer create-project openmage/magento-lts /app/public

Trust the certificate (step 3 above), then navigate to your frontend URL and follow the installation wizard.

Database credentials:

Field Value
Host db
Database openmage
User om_user
Password om_password

Use db as the host, not localhost.

Use Secure URLs: select Yes — Caddy handles SSL automatically.


Security

The Caddyfile includes the following protections on both frontend and admin:

  • Security headersX-Frame-Options: SAMEORIGIN, X-Content-Type-Options, X-XSS-Protection, Referrer-Policy
  • Dot files blocked — any path containing a dot-prefixed segment returns 404
  • Private media blocked/media/customer/, /media/downloadable/, /media/import/ return 404
  • Static asset caching/skin/ and /js/ are served with a 1-year cache header

Frontend only:

  • Admin path blocked — requests to /admin on the frontend domain return 404
  • PHP entry points blocked — direct requests to api.php, get.php, install.php, index.php return 404
  • Admin skin blocked/skin/adminhtml/ and /skin/install/ return 404

Admin only:

  • Upload limit — request body limit raised to 512MB to support large file uploads in the admin panel

phpMyAdmin

Available at https://om-phpmyadmin.127.0.0.1.nip.io (or your configured PHPMYADMIN_URL).

Field Value
User om_user
Pass om_password

For full root access use root / root_password.


Xdebug

Xdebug 3 is included and configured for remote debugging on port 9003 with start_with_request=trigger — it only activates when the XDEBUG_TRIGGER cookie or query parameter is present.

VSCode setup

  1. Install the PHP Debug extension by Xdebug.

  2. Create .vscode/launch.json in your project root:

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Listen for Xdebug",
      "type": "php",
      "request": "launch",
      "port": 9003,
      "pathMappings": {
        "/app/public": "${workspaceFolder}/src"
      }
    }
  ]
}
  1. Start debugging with Run → Start Debugging (F5).

  2. Activate Xdebug per-request by adding ?XDEBUG_TRIGGER=1 to the URL, or use the Xdebug Helper browser extension to toggle it with one click.

Linux: firewall note

On Linux, iptables may block incoming connections from Docker bridge interfaces to the host, preventing Xdebug from reaching your IDE. If breakpoints are not hit, run:

sudo iptables -I INPUT -i br+ -p tcp --dport 9003 -j ACCEPT

To make the rule persistent across reboots:

# Debian / Ubuntu
sudo apt install iptables-persistent
sudo netfilter-persistent save

# Arch Linux
sudo iptables-save > /etc/iptables/iptables.rules
sudo systemctl enable iptables

# ufw
sudo ufw allow in on br+ to any port 9003

# firewalld
sudo firewall-cmd --permanent --add-port=9003/tcp
sudo firewall-cmd --reload

Useful commands

# Start
docker compose up -d

# Stop
docker compose down

# Build (after Dockerfile changes)
docker compose build

# Force rebuild from scratch
docker compose build --no-cache

# View logs
docker compose logs app

# Access the app container
docker exec -it openmage_app bash

# Access the database
docker exec -it openmage_db mariadb -u om_user -pom_password openmage

# Reset and reinstall
./install.sh --reset

Troubleshooting

Maximum execution time exceeded during installation

docker compose build
docker compose up -d

nip.io not resolving

nip.io requires an internet connection for DNS resolution. If you are offline, add the domains manually to /etc/hosts:

echo "127.0.0.1 openmage.127.0.0.1.nip.io" | sudo tee -a /etc/hosts
echo "127.0.0.1 openmage-admin.127.0.0.1.nip.io" | sudo tee -a /etc/hosts
echo "127.0.0.1 om-phpmyadmin.127.0.0.1.nip.io" | sudo tee -a /etc/hosts

Permission denied on var/cache or caddy volumes

sudo chmod -R 777 /var/lib/docker/volumes/openmage-docker_caddy_data/_data
docker compose restart app

Browser blocks the site after switching from HTTPS to HTTP

The Caddyfile sets a Strict-Transport-Security (HSTS) header, which tells the browser to always use HTTPS for a domain for 180 days. If you change your local configuration to HTTP only, the browser will refuse to connect.

To fix it, delete the HSTS entry for the domain:

  1. Go to chrome://net-internals/#hsts
  2. Scroll to Delete domain security policies
  3. Enter the hostname (e.g. openmage.127.0.0.1.nip.io) and click Delete

Repeat for each affected domain. No browser restart required.

{
frankenphp
}
# ─── HTTP → HTTPS redirect ────────────────────────────────────────────────────
# Redirect all insecure requests to HTTPS except the admin domain,
# which may be accessed over HTTP in some internal/proxy setups.
http://{$FRONTEND_HOST}, http://{$PHPMYADMIN_HOST} {
redir https://{host}{uri} 301
}
# ─── FRONTEND ────────────────────────────────────────────────────────────────
{$FRONTEND_HOST} {
tls internal
root * /app/public
encode zstd br gzip
# Security headers
header {
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
X-XSS-Protection "1; mode=block"
Referrer-Policy "strict-origin-when-cross-origin"
Strict-Transport-Security "max-age=15552000;"
}
# Block dot files
@dotfiles path_regexp \\/\\.
respond @dotfiles 404
# Block admin on frontend domain
@admin path /admin /admin/*
respond @admin 404
# Block direct access to PHP entry points
@blocked_php path /api.php /get.php /install.php /index.php
respond @blocked_php 404
# Block private media directories
@private_media path /media/customer/* /media/downloadable/* /media/import/*
respond @private_media 404
# Block admin skin on frontend
@admin_skin path /skin/adminhtml/* /skin/install/*
respond @admin_skin 404
# Static assets - long cache
@static path /skin/* /js/*
handle @static {
header Cache-Control "public, max-age=31536000"
file_server
}
# Media files
@media path /media/*
handle @media {
@media_cacheable path_regexp \.(ico|jpg|jpeg|png|gif|js|css|svg|woff|woff2|ttf|otf|eot)$
header @media_cacheable Cache-Control "public, max-age=31536000"
file_server
}
# Errors directory - only allow CSS and images
@errors_php path_regexp ^/errors/.*\.php$
respond @errors_php 404
php_server {
env MAGE_RUN_CODE default
env MAGE_RUN_TYPE store
}
}
# ─── ADMIN / BACKEND ─────────────────────────────────────────────────────────
{$ADMIN_HOST} {
tls internal
root * /app/public
encode zstd br gzip
# Security headers
header {
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
X-XSS-Protection "1; mode=block"
Referrer-Policy "strict-origin-when-cross-origin"
Strict-Transport-Security "max-age=15552000;"
}
# Block dot files
@dotfiles path_regexp \\/\\.
respond @dotfiles 404
# Block private media directories
@private_media path /media/customer/* /media/downloadable/* /media/import/*
respond @private_media 404
# Larger body size for admin uploads
request_body {
max_size 512MB
}
# Static assets - long cache
@static path /skin/* /js/*
handle @static {
header Cache-Control "public, max-age=31536000"
file_server
}
# Redirect root to admin panel
@root path /
redir @root /index.php/admin 302
php_server {
env MAGE_RUN_CODE admin
env MAGE_RUN_TYPE store
}
}
# ─── PHPMYADMIN ───────────────────────────────────────────────────────────────
{$PHPMYADMIN_HOST} {
tls internal
reverse_proxy openmage_phpmyadmin:80
}
services:
app:
build:
context: .
args:
USER_ID: ${USER_ID:-1000}
container_name: openmage_app
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
environment:
- FRONTEND_HOST=${FRONTEND_HOST}
- ADMIN_HOST=${ADMIN_HOST}
- PHPMYADMIN_HOST=${PHPMYADMIN_HOST}
volumes:
- ./src:/app/public # Place your OpenMage files inside the ./src directory
- ./Caddyfile:/etc/frankenphp/Caddyfile
- caddy_data:/data
- caddy_config:/config
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- openmage-network
depends_on:
- db
db:
image: mariadb:lts
container_name: openmage_db
restart: always
environment:
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
- MYSQL_DATABASE=${MYSQL_DATABASE}
- MYSQL_USER=${MYSQL_USER}
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
ports:
- "3306:3306"
volumes:
- db_data:/var/lib/mysql
networks:
- openmage-network
phpmyadmin:
image: phpmyadmin:5.2
container_name: openmage_phpmyadmin
restart: unless-stopped
environment:
- PMA_HOST=db
- PMA_PORT=3306
ports:
- "8080:80"
networks:
- openmage-network
depends_on:
- db
networks:
openmage-network:
driver: bridge
volumes:
db_data:
caddy_data:
caddy_config:
FROM dunglas/frankenphp:1-php8.3-alpine
RUN apk add --no-cache bash
RUN install-php-extensions \
gd \
intl \
pdo_mysql \
mysqli \
zip \
bcmath \
soap \
opcache \
exif \
pcntl \
xdebug \
ftp \
sockets \
@composer
RUN echo "xdebug.mode=debug" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
echo "xdebug.start_with_request=trigger" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
echo "xdebug.client_host=host.docker.internal" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
echo "xdebug.client_port=9003" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
echo "max_execution_time=300" >> /usr/local/etc/php/conf.d/custom.ini && \
echo "memory_limit=512M" >> /usr/local/etc/php/conf.d/custom.ini
ARG USER_ID=1000
RUN adduser -D -u ${USER_ID} appuser && \
setcap CAP_NET_BIND_SERVICE=+eip /usr/local/bin/frankenphp && \
mkdir -p /data/caddy /config/caddy && \
chown -R appuser:appuser /data/caddy /config/caddy
USER appuser
WORKDIR /app
# User and group ID for the app container
# On Linux, run: id -u and id -g to get your values
# install.sh will set these automatically
USER_ID=1000
# Database
DB_HOST=db
MYSQL_ROOT_PASSWORD=root_password
MYSQL_DATABASE=openmage
MYSQL_USER=om_user
MYSQL_PASSWORD=om_password
# Hostnames
FRONTEND_HOST=openmage.127.0.0.1.nip.io
ADMIN_HOST=openmage-admin.127.0.0.1.nip.io
PHPMYADMIN_HOST=om-phpmyadmin.127.0.0.1.nip.io
# Locale
LOCALE=en_US
TIMEZONE=America/New_York
CURRENCY=USD
SAMPLE_DATA=1
# Admin
ADMIN_EMAIL=admin@example.com
ADMIN_USERNAME=admin
ADMIN_PASSWORD=veryl0ngpassw0rd
ADMIN_FIRSTNAME=OpenMage
ADMIN_LASTNAME=User
#!/bin/bash
set -e
# Copy .env.example to .env if it doesn't exist
if [ ! -f .env ]; then
cp .env.example .env
echo ".env created from .env.example"
fi
# Set UID to the current user's value
sed -i "s/^USER_ID=.*/USER_ID=$(id -u)/" .env
echo "USER_ID=$(id -u) set in .env"
# Create src directory if it doesn't exist
mkdir -p src
# Detect "docker compose" or "docker-compose"
dc="docker compose"
if ! docker compose version >/dev/null 2>&1; then
if ! command -v docker-compose >/dev/null 2>&1; then
echo "Please first install docker compose."
exit 1
else
dc="docker-compose"
fi
fi
# Load .env if exists
test -f .env && source .env
# Config with defaults
DB_HOST="${DB_HOST:-db}"
MYSQL_DATABASE="${MYSQL_DATABASE:-openmage}"
MYSQL_USER="${MYSQL_USER:-om_user}"
MYSQL_PASSWORD="${MYSQL_PASSWORD:-om_password}"
BASE_URL="https://${FRONTEND_HOST}/"
ADMIN_URL="https://${ADMIN_HOST}/"
PHPMYADMIN_URL="https://${PHPMYADMIN_HOST}/"
LOCALE="${LOCALE:-en_US}"
TIMEZONE="${TIMEZONE:-America/New_York}"
CURRENCY="${CURRENCY:-USD}"
ADMIN_EMAIL="${ADMIN_EMAIL:-admin@example.com}"
ADMIN_USERNAME="${ADMIN_USERNAME:-admin}"
ADMIN_PASSWORD="${ADMIN_PASSWORD:-veryl0ngpassw0rd}"
ADMIN_FIRSTNAME="${ADMIN_FIRSTNAME:-OpenMage}"
ADMIN_LASTNAME="${ADMIN_LASTNAME:-User}"
ENABLE_CHARTS="${ENABLE_CHARTS:-yes}"
# Reset flag
if [[ "$1" = "--reset" ]]; then
echo "⚠️ WARNING: This will destroy all containers, volumes, and the src/ directory."
echo "All data including the database and OpenMage files will be permanently deleted."
read -p "Are you sure you want to continue? [y/N] " confirm
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
echo "Aborted."
exit 0
fi
echo "Wiping previous installation..."
$dc down --volumes --remove-orphans
rm -rf ./src && mkdir ./src
fi
# Check if already installed
if test -f ./src/app/etc/local.xml; then
echo "Already installed!"
if [[ "$1" != "--reset" ]]; then
echo ""
echo "Frontend URL: ${BASE_URL}"
echo "Admin URL: ${ADMIN_URL}admin"
echo "Admin login: $ADMIN_USERNAME : $ADMIN_PASSWORD"
echo ""
echo "To start a clean installation run: $0 --reset"
exit 1
fi
fi
# Validate admin password length
if [[ ${#ADMIN_PASSWORD} -lt 14 ]]; then
echo "Admin password must be at least 14 characters."
exit 1
fi
echo "Building containers..."
$dc build
echo "Starting containers..."
$dc up -d
echo "Installing OpenMage via Composer..."
$dc run --rm app composer create-project openmage/magento-lts /app/public
echo "Waiting for MySQL to be ready..."
for i in $(seq 1 30); do
sleep 1
docker exec openmage_db mariadb -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" -e "SELECT 1;" 2>/dev/null && break
echo " waiting... ($i/30)"
done
# Sample data (optional)
if [[ -n "${SAMPLE_DATA:-}" ]]; then
echo "Installing Sample Data..."
SAMPLE_DATA_URL=https://github.com/Vinai/compressed-magento-sample-data/raw/master/compressed-magento-sample-data-1.9.2.4.tgz
SAMPLE_DATA_DIR="./src/var/sample_data"
SAMPLE_DATA_FILE="$SAMPLE_DATA_DIR/sample_data.tgz"
mkdir -p "$SAMPLE_DATA_DIR"
if [[ ! -f "$SAMPLE_DATA_FILE" ]]; then
echo "Downloading Sample Data..."
wget "$SAMPLE_DATA_URL" -O "$SAMPLE_DATA_FILE"
fi
echo "Extracting Sample Data..."
tar xf "$SAMPLE_DATA_FILE" -C "$SAMPLE_DATA_DIR"
cp -r "$SAMPLE_DATA_DIR"/magento-sample-data-1.9.2.4/media/* ./src/media/
cp -r "$SAMPLE_DATA_DIR"/magento-sample-data-1.9.2.4/skin/* ./src/skin/
echo "Importing Sample Data into database..."
$dc exec -T db mariadb -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE" < "$SAMPLE_DATA_DIR"/magento-sample-data-1.9.2.4/magento_sample_data_for_1.9.2.4.sql
rm -rf "$SAMPLE_DATA_DIR"
fi
echo "Installing OpenMage LTS..."
$dc run --rm app php /app/public/install.php \
--license_agreement_accepted yes \
--locale "$LOCALE" \
--timezone "$TIMEZONE" \
--default_currency "$CURRENCY" \
--db_host "$DB_HOST" \
--db_name "$MYSQL_DATABASE" \
--db_user "$MYSQL_USER" \
--db_pass "$MYSQL_PASSWORD" \
--url "$BASE_URL" \
--use_rewrites yes \
--use_secure "$([[ $BASE_URL == https* ]] && echo yes || echo no)" \
--secure_base_url "$BASE_URL" \
--use_secure_admin "$([[ $ADMIN_URL == https* ]] && echo yes || echo no)" \
--enable_charts "$ENABLE_CHARTS" \
--skip_url_validation \
--admin_firstname "$ADMIN_FIRSTNAME" \
--admin_lastname "$ADMIN_LASTNAME" \
--admin_email "$ADMIN_EMAIL" \
--admin_username "$ADMIN_USERNAME" \
--admin_password "$ADMIN_PASSWORD"
echo "Flushing cache..."
rm -rf ./src/var/cache/*
# OpenMage stores the base_url at the 'default' scope, which is used by the frontend.
# To make the admin panel work on a separate domain, we set the base_url at the
# 'stores' scope for store_id=0 (the admin store). OpenMage's config inheritance
# gives 'stores' scope priority over 'default', so the admin will use ADMIN_URL
# for redirects while the frontend continues to use BASE_URL.
echo "Configuring separate admin URL..."
docker exec openmage_db mariadb -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE" -e "
DELETE FROM core_config_data WHERE path IN ('admin/url/use_custom', 'admin/url/custom', 'web/unsecure/base_url', 'web/secure/base_url');
INSERT INTO core_config_data (scope, scope_id, path, value) VALUES
('default', 0, 'admin/url/use_custom', '1'),
('default', 0, 'admin/url/custom', '${ADMIN_URL}admin/'),
('default', 0, 'web/unsecure/base_url', '$BASE_URL'),
('default', 0, 'web/secure/base_url', '$BASE_URL'),
('stores', 0, 'web/unsecure/base_url', '$ADMIN_URL'),
('stores', 0, 'web/secure/base_url', '$ADMIN_URL');
"
rm -rf ./src/var/cache/*
echo ""
echo "✅ Setup complete!"
echo ""
echo "Frontend URL: ${BASE_URL}"
echo "Admin URL: ${ADMIN_URL}admin"
echo "Admin login: $ADMIN_USERNAME : $ADMIN_PASSWORD"
echo ""
echo "phpMyAdmin URL: ${PHPMYADMIN_URL}"
echo "phpMyAdmin login: $MYSQL_USER : $MYSQL_PASSWORD"
echo ""
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment