How to Install Magento 2 on Ubuntu

Written by

in

Magento is one of the most widely used e-commerce platforms for stores that need deep customization — flexible catalogs, multi-store setups, and a huge ecosystem of extensions. Recent Magento 2 releases require OpenSearch as their search engine — Elasticsearch support was removed as of 2.4.9 — so if you haven’t already got OpenSearch running, that has to come first.

This guide assumes OpenSearch is already installed and secured — see our How to Install and Configure OpenSearch on Ubuntu guide if it isn’t. We’ll reference that setup throughout rather than repeat it here.

Like the OpenSearch guide, this one goes slower than a typical install walkthrough on purpose — explaining what each step does and why, including three real problems we hit during our own install and how we actually diagnosed and fixed them, not just the commands that happened to work.

Prerequisites

The specific PHP, database, and search engine versions Magento requires change with every release — pinning them in a guide like this one just guarantees it goes stale. Before you start, check Adobe’s own official System Requirements page for the exact versions your target Magento release needs, and make sure your server matches before installing anything below.

In general terms, you’ll need:

  • PHP, with the standard extensions Magento needs (bcmath, ctype, curl, dom, gd/imagick, intl, mbstring, soap, sockets, sodium, xsl, zip, and OPcache) — check the requirements page above for which PHP version your release supports
  • MySQL or MariaDB, again at whichever version the requirements page lists for your release
  • Composer, a recent 2.x release
  • OpenSearch already installed, secured, and running (see the linked guide above) — Magento validates the connection to it during install, so this has to work first. Older Magento releases used Elasticsearch instead; check the requirements page to confirm which one — and which version of it — your target release expects
  • nginx with PHP-FPM

Step 1: Get Magento Marketplace Access Keys

Magento’s source code isn’t on Packagist like most Composer packages — it’s distributed from Adobe’s own private repository, repo.magento.com, and that repository requires authentication even for the free Open Source edition. The credentials for it aren’t your Adobe account password; they’re a separate public/private key pair generated specifically for Composer access, from your Adobe Commerce Marketplace account under Access Keys.

Composer treats that key pair as HTTP Basic Auth credentials — the public key as the username, the private key as the password — and stores them once, globally, so every project on the machine can reuse them without re-entering anything:

composer global config http-basic.repo.magento.com <public-key> <private-key>

This writes the pair into ~/.config/composer/auth.json (or the equivalent path on your system). Keep that file private — anyone with these keys can pull Magento’s private packages as you, though notably they can’t do anything to your actual store or Adobe account with them, since they’re scoped to package downloads only.

Step 2: Create a Dedicated Database and User

Same principle as any production database: Magento gets its own database and its own MySQL user scoped to only that database, rather than sharing credentials with anything else running on the box.

mysql -u root -p -e "
CREATE DATABASE magento CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
CREATE USER 'magento_user'@'localhost' IDENTIFIED BY 'a-strong-password-here';
GRANT ALL PRIVILEGES ON magento.* TO 'magento_user'@'localhost';
FLUSH PRIVILEGES;
"

If MySQL rejects your password with something like ERROR 1819: Your password does not satisfy the current policy requirements, that’s the validate_password plugin enforcing a minimum bar (typically mixed case, a digit, and a special character) — not a bug, just make the password meet it.

Step 3: Install Magento via Composer

With the marketplace keys configured, Composer can now pull the actual Magento codebase. Leaving the version off installs the latest stable release; if the requirements page above pointed you at a specific version instead, pin it explicitly with =2.4.9 (or whichever version you need) appended to the package name:

composer create-project --repository-url=https://repo.magento.com/ \
  magento/project-community-edition /path/to/your/docroot

Expect this to take several minutes — Magento’s dependency tree is enormous (well over a thousand packages between the core modules and their internal split-up sub-modules), and the initial download alone is over a gigabyte. This is normal; there’s nothing to troubleshoot here unless it actually errors out.

Real Issue: MySQL Blocks Trigger Creation (“SUPER privilege”)

The next step is running Magento’s installer, and this is where we hit our first real failure — over a thousand progress steps in, right as it tried to set up inventory tracking:

SQLSTATE[HY000]: General error: 1419 You do not have the SUPER privilege and
binary logging is enabled (you *might* want to use the less safe
log_bin_trust_function_creators variable), query was: CREATE TRIGGER
trg_cataloginventory_stock_item_after_insert AFTER INSERT ON
cataloginventory_stock_item FOR EACH ROW ...

What’s actually going on: Magento’s inventory system relies on database triggers to keep stock data in sync. MySQL 8’s binary logging (used for replication and point-in-time recovery, and commonly on by default) has a safety rule: creating a trigger or stored function can be a replication hazard, so only an account with the SUPER privilege — or one that binary logging has been told to explicitly trust — is allowed to create one. Our magento_user account deliberately has neither, because it was scoped down to just its own database in Step 2. That’s not a misconfiguration; least-privilege database accounts are supposed to lack broad instance-wide privileges like SUPER.

The fix — and the one we deliberately didn’t take: the tempting shortcut is to just grant SUPER to magento_user, which would make the error disappear immediately. We didn’t do that, because SUPER is a sweeping, instance-wide privilege (it can kill other sessions, change global server variables, control replication, and more) — handing it to an application’s database account just to unblock trigger creation is a much bigger blast radius than the actual problem calls for. The setting MySQL’s own error message points to is the correct, narrower fix: log_bin_trust_function_creators, which says “trust any authenticated user account to create triggers/functions,” without granting any of SUPER‘s other capabilities.

SET GLOBAL log_bin_trust_function_creators = 1;

That takes effect immediately but only until the next restart, so persist it properly in MySQL’s config so it survives a reboot:

echo "[mysqld]
log_bin_trust_function_creators=1" | sudo tee /etc/mysql/mysql.conf.d/log_bin_trust_function_creators.cnf

sudo systemctl restart mysql

If the installer had already partially run when it hit this, drop and recreate the database before retrying — a half-completed schema install can cause confusing secondary errors otherwise:

mysql -u root -p -e "DROP DATABASE magento; CREATE DATABASE magento CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;"

Step 4: Run the Installer

This is the command that actually sets up the database schema, writes the configuration, and connects everything together:

cd /path/to/your/docroot

php bin/magento setup:install \
  --base-url=http://your-domain.example/ \
  --db-host=127.0.0.1 \
  --db-name=magento \
  --db-user=magento_user \
  --db-password="your-db-password" \
  --admin-firstname=Admin \
  --admin-lastname=User \
  [email protected] \
  --admin-user=admin \
  --admin-password="a-strong-admin-password" \
  --language=en_US \
  --currency=USD \
  --timezone=UTC \
  --use-rewrites=1 \
  --search-engine=opensearch \
  --opensearch-host=https://127.0.0.1 \
  --opensearch-port=9200 \
  --opensearch-index-prefix=magento2 \
  --opensearch-enable-auth=1 \
  --opensearch-username=admin \
  --opensearch-password="your-opensearch-admin-password" \
  --opensearch-timeout=15 \
  --backend-frontname=admin \
  --cleanup-database

Two flags are easy to get wrong and worth calling out on purpose:

  • --opensearch-host=https://127.0.0.1 — note the explicit https://. If your OpenSearch has the security plugin enabled (as it should — see our OpenSearch guide), it only accepts TLS connections. Give this flag a bare hostname instead and Magento will silently try plain HTTP, fail to connect, and report a misleading No alive nodes found in your cluster error that has nothing to do with your cluster’s actual health. If you also see that error, and your OpenSearch uses a self-signed certificate (the default from a fresh install), you’ll additionally need Magento’s PHP process to trust that certificate — trusting the OpenSearch CA system-wide (update-ca-certificates) is the fix; our OpenSearch guide covers this exact failure in depth, since it’s really an OpenSearch/TLS issue more than a Magento one.
  • --backend-frontname=admin — this sets your admin panel’s URL path. Leaving it as the default admin means every bot that has ever scanned the internet for Magento installs already knows exactly where to try brute-forcing your login. Set it to something unpredictable instead (we come back to this in the security checklist below).

This step takes a while too — it’s running schema installs and recurring setup for roughly 1,400+ individual modules, then indexing. A successful run ends with:

[SUCCESS]: Magento installation complete.
[SUCCESS]: Magento Admin URI: /admin

Step 5: Configure nginx

Magento ships its own recommended nginx configuration (nginx.conf.sample in the project root) — start from that rather than writing one from scratch, since it already encodes a lot of Magento-specific knowledge about which paths need to be denied, cached, or rewritten. Point root at the project’s pub/ directory, not the project root itself — pub/ is the only directory meant to be web-accessible; everything else (app/, vendor/, var/) should never be directly reachable over HTTP.

Two things worth fixing proactively rather than discovering the hard way:

Response headers are bigger than nginx’s defaults expect. Magento sets a substantial number of cookies and security headers, and with nginx’s out-of-the-box FastCGI buffer sizes, that’s enough to trip upstream sent too big header while reading response header from upstream — a 502 with no other explanation. Set generous buffers up front:

fastcgi_buffer_size 16k;
fastcgi_buffers 4 16k;

Don’t casually broaden a “deny by extension” rule to include php. We introduced this bug ourselves while adapting the sample config from memory: a generic rule denying access to files by extension (.phtml, .py, .sh, and similar) accidentally included bare php in the list. That rule sits earlier in the file than the actual PHP-FPM handler block, and nginx evaluates regex location blocks in the order they’re written — whichever one matches first wins, regardless of how specific a later block is. Since every request eventually resolves internally to something like /index.php, that one misplaced extension silently denied the entire site with a plain 403 Forbidden and no indication of which rule caused it. If you hit a mysterious blanket 403 on every single URL including the homepage, check your deny-by-extension rules for exactly this before suspecting file permissions or anything else — it’s a one-word bug that produces a very confusing symptom.

Step 6: Fix File Permissions

PHP-FPM typically runs as its own unprivileged user (www-data on Debian/Ubuntu), while the Magento files themselves are usually owned by whichever user ran Composer. That mismatch causes two opposite problems if you don’t handle it deliberately:

Magento needs several directories to be writable by the web server at runtime — var/, generated/, pub/static/, and pub/media/ — because it generates interception/proxy classes and compiled static assets on the fly, especially in developer mode. If www-data can’t write there, you’ll get errors like:

ReflectionException: Class "Magento\Framework\App\Http\Interceptor" does not exist

which reads like a code problem but is really just “the web server tried to generate this class file and couldn’t write it.” At the same time, Magento’s own post-install output recommends locking down app/etc (it holds your database credentials in env.php) by removing write access from anyone but the owner — but doing that too literally, with the file still owned by your personal user, locks www-data out of reading it too, which breaks the entire site with a blunt “Autoload error.”

Rather than reaching for chown -R www-data on the whole project (which then locks out your own user for day-to-day development) or making everything world-writable (which is a real security downgrade, not just an inconvenience), add the web server user to the same group as your own account, and use group permissions to share access both ways:

sudo usermod -aG your-username www-data
sudo systemctl restart php-fpm

chmod -R g+w var generated pub/static pub/media
chmod 750 app/etc
chmod 640 app/etc/env.php app/etc/config.php

Group membership changes only take effect for new processes, which is why PHP-FPM needs a restart after the usermod — reloading isn’t enough. With this in place, the writable directories are group-writable, app/etc is 750/640 exactly as Magento recommends, and www-data can still read it through group membership instead of through world-readable permissions.

Step 7: Set Up Cron

Magento relies on cron for a lot of what makes it actually usable day to day — reindexing, email queues, cache cleanup, scheduled price rules. Without it, changes you make in the admin panel (a new product, an updated price) may not show up on the storefront until you manually reindex.

php bin/magento cron:install

This writes the necessary entries into the current user’s crontab, running bin/magento cron:run every minute and logging to var/log/magento.cron.log.

Step 8: Verify Everything Actually Works

Don’t just check that the homepage loads — confirm the pieces that tend to fail silently:

curl -sL http://your-domain.example/ -o /dev/null -w "storefront: %{http_code}\n"
curl -sL http://your-domain.example/admin -o /dev/null -w "admin: %{http_code}\n"
php bin/magento indexer:status

Both requests should come back 200, and every indexer should show Ready. If the admin login page loads but looks unstyled or broken, that’s usually a static-content generation problem, not a search or database issue — check that pub/static is actually writable by the web server per Step 6.

Security Checklist Before Going Further

  • ☐ Admin panel path (--backend-frontname) is something other than the default admin
  • ☐ Admin password is long, random, and unique — this account has full control over the store
  • app/etc/env.php and app/etc/config.php are not world-readable (verify with ls -la, not just by assuming the chmod worked)
  • ☐ MySQL and OpenSearch are still bound to 127.0.0.1 only, exactly as in their own setup guides — installing Magento doesn’t change that, but it’s worth re-confirming nothing along the way opened anything up
  • ☐ Only the web server’s writable directories (var, generated, pub/static, pub/media) were made group-writable — not the whole project

With all of that in place, you’ve got a working Magento 2 install talking to a properly secured OpenSearch instance, cron keeping it in sync, and the file permission model actually matching how the web server and your own user need to share access — rather than either being locked out or everything being wide open.

Comments

Leave a Reply

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