Author: malvicadmin

  • How to Install Magento 2 on Ubuntu

    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.

  • How to Install and Configure OpenSearch on Ubuntu

    OpenSearch is the search and analytics engine behind full-text search, faceted navigation, and log analytics on modern web stacks. It’s also become the go-to replacement for Elasticsearch on many self-hosted platforms as licensing changes have pushed projects to migrate away from it. If you need fast, relevant search over large datasets, OpenSearch is the piece that sits behind the scenes doing the heavy lifting.

    This guide walks through the whole thing from zero: what each command actually does, why it’s there, and — because this trips people up constantly — how to make sure OpenSearch is never reachable by anyone except the application running on the same machine. We’ll go slower than a typical install guide on purpose, so you can follow along even if you’ve never installed a search engine before.

    Why Security Is the Main Character of This Post

    Before touching a single command, it’s worth understanding why so much of this guide is about locking things down rather than just “getting it running.”

    Search and database engines like OpenSearch, Elasticsearch, MongoDB, and Redis have a long, well-documented history of being left open to the public internet — usually because a tutorial somewhere said “just bind to 0.0.0.0 so it’s easy to connect to,” or because a cloud firewall rule was left too permissive. Automated bots constantly scan the entire internet for exactly these ports (9200 for OpenSearch/Elasticsearch, 27017 for MongoDB, 6379 for Redis, and so on). The moment one of these services responds on a public IP without authentication, it gets found — usually within hours, sometimes minutes. What follows is one of a few outcomes, all bad: the entire dataset is copied out silently, the entire dataset is deleted and replaced with a ransom note demanding payment for its return (this has happened to tens of thousands of exposed Elasticsearch and MongoDB instances at once, in automated, indiscriminate sweeps), or the server itself gets used as a foothold to attack other things on your network.

    None of that requires the attacker to be sophisticated or to target you specifically. It just requires the port to be open. That’s the entire reason this guide spends real time on two things: making sure OpenSearch never listens anywhere except 127.0.0.1 (your own machine, talking to itself), and making sure it has a real password instead of a well-known default. Do both, and the entire class of attack described above simply doesn’t apply to you — there’s no route in.

    Prerequisites

    • A server running Ubuntu, with apt and curl available
    • A non-root user with sudo privileges
    • At least 1–2 GB of RAM free for OpenSearch’s JVM heap (more if you’re indexing a large catalog)
    • No separate Java install needed — OpenSearch ships with its own bundled JDK, so you don’t need to install or manage a Java version yourself

    Step 1: Import the OpenSearch Signing Key

    What this step does and why it exists: every package your system installs via apt comes from a repository — basically a folder of software hosted on someone’s server. Nothing stops a network in the middle, a compromised mirror, or a malicious actor from tampering with those files in transit and slipping in a modified package. To stop that, projects like OpenSearch cryptographically sign their release metadata with a private key that only they hold. Your system then needs the matching public key so apt can check: “does this signature actually match what OpenSearch’s real private key would have produced?” If yes, the files are authentic and untampered. If no — or if there’s no key to check against at all — apt refuses to install anything from that repository. That refusal is a safety feature working as intended, not a bug to route around.

    curl -sSL -o /tmp/opensearch.pgp https://artifacts.opensearch.org/publickeys/opensearch.pgp
    sudo gpg --dearmor --batch --yes -o /usr/share/keyrings/opensearch-keyring.gpg /tmp/opensearch.pgp
    rm -f /tmp/opensearch.pgp

    Breaking down what each line does:

    • curl ... -o /tmp/opensearch.pgp downloads OpenSearch’s official public key from their own HTTPS domain and saves it to a temporary file.
    • gpg --dearmor converts the key from “ASCII-armored” format (the human-readable -----BEGIN PGP PUBLIC KEY BLOCK----- text block you’d see if you opened the file) into the compact binary format apt expects for a keyring file. Think of it as changing the file’s packaging, not its contents.
    • The output is saved specifically to /usr/share/keyrings/opensearch-keyring.gpg, a file dedicated to this one repository — rather than the old-style global apt-key add, which dumped a key into one big trust store shared by every repo on the system. That older approach meant a key added for one piece of software was implicitly trusted to sign packages for anything else too — a much bigger blast radius if that one key were ever misused. Keeping a separate keyring file per repository (which we’ll reference explicitly in Step 2) avoids that.

    Deep Dive: What Happens When apt Says “NO_PUBKEY”

    Here’s a real problem we ran into, and it’s common enough with the OpenSearch repo that you should expect it: after adding the repository in the next step and running apt update, you may see an error like this:

    W: OpenPGP signature verification failed: ... InRelease: The following signatures
       couldn't be verified because the public key is not available: NO_PUBKEY 4E9275EE6BA2427F
    E: The repository '...' is not signed.

    Let’s unpack this properly, because “just fetch the missing key” isn’t very satisfying without understanding what a key ID even is.

    What is a “key ID”? A GPG key isn’t one single key — it’s a keypair (public + private) that has a long, unique fingerprint, similar in spirit to a serial number. A “key ID” is just a shorter, more convenient stand-in for that fingerprint — typically the last 16 hexadecimal characters of it — used so humans and tools can reference a specific key without typing out the whole fingerprint every time. In the error above, 4E9275EE6BA2427F is that ID. It’s telling you: “the repository’s metadata file was signed by a key with this exact ID, and I don’t have a copy of that public key to check the signature against.”

    Why didn’t Step 1 already cover this? Projects often sign releases with more than one key or subkey over time — for example, a primary key plus one or more “release signing” subkeys that get rotated periodically. The single key file published at OpenSearch’s public-keys URL doesn’t necessarily include every subkey that has ever signed a package. So apt is checking a real signature against a real key that does exist — it’s just not the one you already downloaded.

    Important: you do not “generate” this key yourself. This isn’t about creating your own new keypair — the key already exists; it belongs to the OpenSearch project. What you’re doing is fetching a copy of a public key that already exists, the same way you’d look up someone’s public phone number rather than inventing one for them. That copy is stored on public keyservers — essentially open, shared directories that anyone can upload a public key to, or query by key ID or fingerprint. keyserver.ubuntu.com is one such directory, commonly used for exactly this kind of lookup.

    Here’s how to actually fetch it, using the real key ID from the error above as a worked example:

    gpg --no-default-keyring --keyring /tmp/os-extra.gpg \
      --keyserver keyserver.ubuntu.com --recv-keys 4E9275EE6BA2427F
    
    gpg --no-default-keyring --keyring /tmp/os-extra.gpg --export 4E9275EE6BA2427F \
      | sudo tee -a /usr/share/keyrings/opensearch-keyring.gpg > /dev/null
    
    rm -f /tmp/os-extra.gpg /tmp/os-extra.gpg~

    What each part is actually doing:

    • --no-default-keyring --keyring /tmp/os-extra.gpg tells GPG “don’t touch my personal keyring, use this disposable temporary file instead.” This keeps the fetch isolated and easy to clean up — we’re not trying to permanently trust this key for GPG operations in general, only to extract it and hand it to apt for one specific repository.
    • --keyserver keyserver.ubuntu.com --recv-keys 4E9275EE6BA2427F is the actual lookup: “go ask this keyserver for the public key matching this ID, and download it into the keyring I specified.”
    • --export 4E9275EE6BA2427F then reads that key back out of the temporary keyring in the binary format apt needs, and pipes it straight into the same keyring file from Step 1 (tee -a appends rather than overwrites, so the original key is preserved alongside this one).
    • The final line just deletes the temporary keyring files — they’ve done their job once the key is copied into the real one.

    A honest caveat worth knowing: pulling a key from a public keyserver by ID alone is standard, widely-used practice across the Debian/Ubuntu ecosystem for third-party repositories — but it is a slightly weaker guarantee than, say, verifying a fingerprint published over HTTPS directly by the project. Keyservers will happily store a key uploaded by anyone, for any name — the ID itself doesn’t prove who controls it. In our case, we already trust the OpenSearch project because we fetched their primary key directly from their own HTTPS domain in Step 1, and this subkey is only being used to complete verification of packages coming from that same official OpenSearch repository. For extra assurance in a stricter environment, you can cross-check the fingerprint of a fetched key against one published in OpenSearch’s own official documentation before trusting it.

    Step 2: Add the OpenSearch APT Repository

    Why: Ubuntu’s default package repositories don’t carry OpenSearch, so we need to tell apt about an additional one — OpenSearch’s own official repository — and, using signed-by, explicitly tie it to the keyring file we just built. This is the modern, scoped alternative to the old global apt-key approach mentioned earlier: this specific key is only trusted for this specific repository, nothing else.

    echo "deb [signed-by=/usr/share/keyrings/opensearch-keyring.gpg] https://artifacts.opensearch.org/releases/bundle/opensearch/3.x/apt stable main" \
      | sudo tee /etc/apt/sources.list.d/opensearch-3.x.list
    
    sudo apt-get update

    The first command writes a single line describing the repository into its own file under /etc/apt/sources.list.d/ (each third-party repo typically gets its own file here, rather than being crammed into one shared list — makes it easy to remove later if needed). apt-get update then refreshes apt’s local knowledge of what packages are available, downloading and — this time successfully — verifying that repository’s metadata against the keys we imported.

    Confirm the package is visible and see which version you’ll get:

    apt-cache policy opensearch

    Step 3: Set a Real Admin Password Before Installing

    Why this step exists: earlier versions of OpenSearch (and Elasticsearch before it) would happily start up with a well-known default login like admin/admin if you didn’t configure anything. Combined with instances left open to the internet (see the security section above), this was a huge contributor to real-world breaches — no hacking skill required, just try the documented default password on any exposed instance. Since version 2.12, OpenSearch’s security plugin refuses to fall back to that default at all. You’re required to set a real password up front, via the OPENSEARCH_INITIAL_ADMIN_PASSWORD environment variable, before the package finishes installing — its post-install script reads this variable and configures the built-in admin account with it.

    sudo env OPENSEARCH_INITIAL_ADMIN_PASSWORD='your-strong-password-here' \
      apt-get install -y opensearch

    Use a genuinely strong, unique password here (a long random string from a password manager is ideal) — this account has full administrative access to every index and every document OpenSearch holds.

    Notice the use of env VAR=value command rather than sudo -E. sudo -E only preserves environment variables that already exist in your current shell — it won’t inject a brand-new one that wasn’t already exported beforehand, and depending on your system’s sudo configuration it may silently be ignored entirely (you’ll actually see a warning to that effect). It’s an easy trap: the command appears to succeed, but the password was never actually passed through. env VAR=value command instead explicitly sets that variable for the one command being run, regardless of sudo’s environment policy. Afterwards, double-check it actually took effect by looking for this exact line in the install log:

    sudo grep "Admin password set successfully" /var/log/opensearch/install_demo_configuration.log

    If that line isn’t there, the account may have ended up in a state where you’ll need to reset it manually with OpenSearch’s securityadmin.sh tool before it’s safe to use — don’t skip this check.

    Step 4: Check the vm.max_map_count Kernel Setting

    Why: under the hood, OpenSearch stores its indexes using Lucene, which relies heavily on memory-mapped files — a technique where a file on disk is mapped directly into a process’s memory space for fast access, rather than being read through slower normal file I/O. Every index segment needs its own memory mapping, and a busy OpenSearch node can easily need hundreds of thousands of them. Linux caps how many memory mappings a single process is allowed to have, and the out-of-the-box default is far too low for this. If it’s too low, OpenSearch will fail one of its built-in “bootstrap checks” and simply refuse to start — a deliberate safety check rather than a crash.

    sysctl vm.max_map_count

    It needs to be at least 262144. If it’s lower, raise it — and make sure the change survives a reboot by writing it into /etc/sysctl.conf rather than only setting it for the current session:

    echo "vm.max_map_count=262144" | sudo tee -a /etc/sysctl.conf
    sudo sysctl -p

    Step 5: Restricting Network Access — the Local-Only Setup, Explained Properly

    This is the step that actually determines whether everything in the security section above applies to your server or not, so let’s slow down here.

    What does “binding” even mean? When a program like OpenSearch starts a network service, it has to choose which network interface(s) it will accept incoming connections on — this is called the bind address. A server typically has several: a loopback interface (always 127.0.0.1, meaning “this same machine talking to itself”), and one or more real network interfaces with the machine’s actual LAN or public IP address. Whatever address a service binds to determines who is even allowed to attempt a connection — this is decided before any password or permission check ever happens.

    • 0.0.0.0 means “accept connections arriving on any network interface this machine has” — including its public IP, if it has one. Many tutorials default to this because it’s the path of least resistance for getting something to “just work” from another machine. It is also exactly what the scanning bots described earlier are searching for.
    • 127.0.0.1 (the loopback address, aka localhost) means “only accept connections that originate from this exact same machine.” A connection attempt from anywhere else — another computer on the same office Wi-Fi, let alone the open internet — physically never reaches this interface. It’s not a permissions check that could theoretically be bypassed; the packets simply have nowhere else to go. This is the strongest, simplest guarantee available, and it’s all a single-server setup like this one needs, since whatever application talks to OpenSearch runs on that very same machine.

    With that in mind, here’s how we actually configure it. Add these lines to OpenSearch’s main config file:

    sudo tee -a /etc/opensearch/opensearch.yml > /dev/null <<'EOF'
    
    network.host: 127.0.0.1
    http.port: 9200
    discovery.type: single-node
    cluster.name: my-opensearch-cluster
    node.name: node-1
    EOF

    What each setting does:

    • network.host: 127.0.0.1 — the actual lockdown. This is the setting that makes OpenSearch bind only to the loopback interface, as explained above.
    • http.port: 9200 — the port your application will connect to (this is the default; stated explicitly here for clarity).
    • discovery.type: single-node — OpenSearch is designed to run as a multi-machine cluster that automatically finds its peers (“discovery”). On a single server, there are no peers to find, so without this setting OpenSearch will sit there waiting to discover other nodes that don’t exist, and never fully initialize. This tells it, explicitly, “you are the only node, don’t wait for company.”
    • cluster.name / node.name — just human-readable labels, useful once you’re looking at logs or multiple environments and need to tell them apart. They have no security implication.

    Why do this at the application level if a firewall could also block the port? Because it’s a second, independent layer of protection — not a replacement for a firewall, a complement to it. If the server also runs a firewall like ufw, keep its default policy of denying incoming connections, and simply don’t add any rule that opens port 9200 or 9300. That way, even if someone accidentally changes network.host back to 0.0.0.0 in the future, the firewall still blocks external access. And conversely, even if a firewall rule is ever misconfigured or temporarily disabled for troubleshooting, OpenSearch itself still isn’t listening anywhere reachable. Two independent locks on the same door — a mistake in one doesn’t undo the other. This same reasoning is why we also generated a real admin password in Step 3 even though the network is locked down: defense in depth means no single mistake is enough to cause a breach.

    If you ever do have a legitimate need for a second server to reach this OpenSearch instance — say, a separate application server — the right approach is still not to open it to 0.0.0.0. Prefer a private network / VPN between the two machines, or an SSH tunnel, or, at minimum, a firewall rule that allows only that one specific server’s IP address rather than the entire internet.

    Step 6: Enable and Start the Service

    Why: installing the package doesn’t start it or set it up to run automatically — that’s deliberate, so you have a chance to configure it (as we just did) before it ever accepts connections.

    sudo systemctl daemon-reload
    sudo systemctl enable opensearch.service
    sudo systemctl start opensearch.service
    sudo systemctl status opensearch.service
    • daemon-reload tells systemd to re-read unit files, picking up the one the package just installed.
    • start runs it right now, for this session.
    • enable is the one people forget — it’s what makes OpenSearch come back up automatically the next time the server reboots (for a kernel update, a power loss, anything). Without it, start alone means the service is running today but silently won’t be there after the next restart, which usually gets discovered at the worst possible time.

    Step 7: Verify It’s Running — and Only Locally

    First, confirm it actually responds, using the admin credentials from Step 3:

    curl -sk -u admin:'your-strong-password-here' https://127.0.0.1:9200

    A few things worth explaining in that single line: it’s https, not http, because OpenSearch’s security plugin encrypts traffic by default — even traffic that never leaves the machine, since defense in depth applies to encryption too, not just network access. The -k flag tells curl to skip certificate validation, which is fine and expected here: the certificate is a self-signed “demo” certificate generated automatically at install time, meant only for this kind of local/internal use, not for proving identity to the public internet. You should get back a small JSON response containing the cluster name and OpenSearch version — that’s confirmation the service is up, the password works, and TLS is functioning.

    Then, separately, confirm where it’s actually listening:

    ss -tlnp | grep -E '9200|9300'

    This command lists all TCP ports currently listening for connections (-t for TCP, -l for listening sockets only, -n to show raw addresses/ports instead of resolving names, -p to show which process owns each socket), filtered down to just the two ports OpenSearch uses: 9200 for the HTTP API your application talks to, and 9300 for internal node-to-node cluster communication.

    Both lines should show 127.0.0.1 as the bound address — not 0.0.0.0 or your server’s public IP. Here’s precisely why that distinction matters, spelled out: if that column instead showed 0.0.0.0, it would mean the socket is listening on every interface, so anyone who can send a packet to your server’s public IP address — meaning literally anyone on the internet — can attempt a TCP connection to port 9200. From that point on, whether they can actually do anything harmful depends entirely on the security plugin and password holding up perfectly with zero misconfiguration, forever. That’s a much weaker position than “the connection can’t even reach the port to begin with,” which is what 127.0.0.1 guarantees. This one line of ss output is, in effect, the final proof that the security work from Step 5 actually took effect — always check it after any config change to OpenSearch, not just the first time.

    Real Issue: “No Alive Nodes Found” When an Application Connects

    Everything above gets OpenSearch itself installed, secured, and running. But the first time we actually pointed a real application at it — one whose installer validates the search engine connection as part of setup — it failed with an error that had nothing to do with anything covered so far:

    Could not validate a connection to the OpenSearch. No alive nodes found in your cluster

    This is worth walking through because the error message is misleading — OpenSearch was alive and perfectly healthy the whole time. Here’s what was actually happening, and it comes straight back to two things this guide already covered: TLS and network binding.

    The actual cause: the security plugin’s demo installer (Step 3) enables TLS on the HTTP layer by default, which is why every curl example in this guide uses https://127.0.0.1:9200, never plain http://. Port 9200 only speaks TLS — a plain HTTP request to it doesn’t get an error response, it just gets nothing, because there’s no unencrypted listener there to answer. The application’s installer, when only given a bare host like 127.0.0.1 with no scheme, defaults to building a plain http:// connection string. That connection attempt doesn’t fail loudly with something like “wrong protocol” — the underlying search client library just can’t complete a handshake, gives up, and reports it as “no alive nodes,” which sounds like a cluster health problem rather than what it actually is: a protocol mismatch.

    We verified this directly before touching any config, which is a good habit whenever a client library’s error message doesn’t quite match reality — go straight to the protocol level and check both possibilities yourself:

    curl -s -m 5 http://127.0.0.1:9200 -o /dev/null -w "http status: %{http_code}\n"
    curl -sk -m 5 https://127.0.0.1:9200 -o /dev/null -w "https status: %{http_code}\n"

    The first came back 000 (no response at all — connection refused/reset at the protocol level); the second came back 200. That one comparison confirmed the theory in seconds, before spending any time second-guessing OpenSearch’s own health or config.

    The fix, part one — force the client to actually use HTTPS. Most tools that accept a bare “host” setting will build the connection URL themselves, defaulting to plain HTTP unless the scheme is explicit. The fix is simply to give it the scheme up front rather than a bare hostname — in our case, that meant passing https://127.0.0.1 as the host value instead of 127.0.0.1. This generalizes to any application you connect to a TLS-enabled OpenSearch: check whether its “host” setting wants a bare hostname or a full URL, and don’t assume it infers the scheme correctly.

    The fix, part two — get past the self-signed certificate. Forcing HTTPS alone isn’t quite enough. The certificate Step 3’s installer generated is self-signed (that’s what made the -k flag necessary in every curl example in this guide — it tells curl to skip verifying who signed the certificate). A browser or a quick manual curl check can get away with skipping that check; a real application’s HTTP client usually verifies certificates properly by default, and it should — that’s not a setting you want to casually switch off, since it’s the thing that stops something else from silently impersonating your OpenSearch endpoint. The correct fix isn’t to tell the application to stop verifying certificates — it’s to make the machine actually trust the certificate that’s already there, by registering OpenSearch’s own CA as a trusted authority system-wide:

    sudo cp /etc/opensearch/root-ca.pem /usr/local/share/ca-certificates/opensearch-local-ca.crt
    sudo update-ca-certificates

    This copies the CA certificate that Step 3’s demo installer generated (the same file listed in the paths table below) into the system’s trusted-CA directory and rebuilds the OS-wide certificate bundle. From that point on, any properly-behaved HTTPS client on this machine — PHP’s curl extension included — verifies the certificate successfully instead of rejecting it, without disabling verification anywhere. You can confirm it worked with a quick PHP one-liner before touching the application at all:

    php -r '
    $ch = curl_init("https://127.0.0.1:9200");
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_USERPWD, "admin:your-strong-password-here");
    $result = curl_exec($ch);
    echo $result === false ? "CURL ERROR: " . curl_error($ch) : "SUCCESS";
    '

    A path we deliberately did not take: the quickest-looking “fix” here would have been to just turn the security plugin off entirely (plugins.security.disabled: true), removing TLS and authentication from the equation altogether. On a machine that’s already loopback-only and never exposed to the internet, that’s a tempting shortcut — and it’s exactly the kind of shortcut worth resisting on principle, because “this box happens to be safe today” is a fragile reason to remove a real security control, and the fix above (trusting a certificate you already generated yourself) is barely more work and leaves the security plugin, TLS, and authentication fully intact. When a security feature is inconvenient, the better question is usually “how do I work correctly with this” rather than “how do I turn this off” — the CA-trust approach here is a small, concrete example of choosing the former.

    Key Paths to Know

    PathPurpose
    /etc/opensearch/opensearch.ymlMain configuration file
    /etc/opensearch/root-ca.pemCA certificate for the self-signed demo TLS certs
    /var/lib/opensearch/Index data
    /var/log/opensearch/Logs, including the install/demo-cert log
    /etc/opensearch/jvm.optionsJVM heap size (-Xms / -Xmx, default 1 GB)

    Quick Security Checklist

    Before considering this done, confirm every one of these:

    • network.host in opensearch.yml is 127.0.0.1, not 0.0.0.0 or a public IP
    • ss -tlnp | grep -E '9200|9300' confirms this in practice, not just in the config file
    • ☐ The install log confirms Admin password set successfully with a real, unique, strong password — never the default
    • ☐ Any server firewall (ufw or equivalent) has no rule allowing inbound 9200 or 9300 from outside
    • systemctl is-enabled opensearch reports enabled, so it survives a reboot

    With all five checked, OpenSearch is installed, running under its own unprivileged system user, completely unreachable from outside the machine, and set to come back up automatically after a reboot. From here, point your application (a custom search feature, an e-commerce platform, whatever’s consuming it) at https://127.0.0.1:9200 with the admin credentials, and you’re good to go.

  • How to Install and Configure Nginx on Ubuntu

    Nginx is one of the most widely used web servers today, known for its speed, low memory footprint, and ability to handle high traffic loads. Whether you’re hosting a WordPress site, a Magento store, or a custom application, Nginx is often the first layer your visitors’ requests hit.

    In this guide, we’ll walk through setting up Nginx from scratch on an Ubuntu server — the same process we followed when setting up our own production server.

    Note: This post covers installation and basic configuration only. Server hardening and security parameters are covered in a separate post — “How to Secure Your Server” — coming soon.

    Prerequisites

    • A server running Ubuntu 22.04 or 24.04 LTS
    • A non-root user with sudo privileges
    • A registered domain name (optional, but needed if you want to serve a real site)

    Step 1: Update the System

    Always start with an updated package list:

    sudo apt update && sudo apt upgrade -y

    Step 2: Install Nginx

    sudo apt install nginx -y

    Once installed, Nginx starts automatically. Verify it’s running:

    sudo systemctl status nginx

    You should see active (running) in the output.

    Step 3: Allow Nginx Through the Firewall

    If ufw is enabled, allow HTTP/HTTPS traffic:

    sudo ufw allow 'Nginx Full'
    sudo ufw status

    Nginx Full opens both port 80 (HTTP) and 443 (HTTPS).

    Step 4: Verify in the Browser

    Visit your server’s public IP address:

    http://your-server-ip

    You should see the default “Welcome to nginx!” page. This confirms Nginx is installed and serving traffic correctly.

    Step 5: Understand the Nginx Directory Structure

    A few key locations you’ll work with regularly:

    PathPurpose
    /etc/nginx/nginx.confMain configuration file
    /etc/nginx/sites-available/Where you define server blocks (virtual hosts)
    /etc/nginx/sites-enabled/Symlinks to active server blocks
    /var/www/Default web root for your sites
    /var/log/nginx/Access and error logs

    Step 6: Create a Server Block for Your Domain

    Create the web root directory:

    sudo mkdir -p /var/www/yourdomain.com/html
    sudo chown -R $USER:$USER /var/www/yourdomain.com/html

    Create a new server block configuration:

    sudo nano /etc/nginx/sites-available/yourdomain.com

    Add the following:

    server {
        listen 80;
        listen [::]:80;
    
        root /var/www/yourdomain.com/html;
        index index.html index.htm index.php;
    
        server_name yourdomain.com www.yourdomain.com;
    
        location / {
            try_files $uri $uri/ =404;
        }
    }

    Step 7: Enable the Site

    Create a symlink to sites-enabled:

    sudo ln -s /etc/nginx/sites-available/yourdomain.com /etc/nginx/sites-enabled/

    Test the configuration for syntax errors:

    sudo nginx -t

    If the test passes, reload Nginx:

    sudo systemctl reload nginx

    Step 8: Point Your Domain and Add SSL

    Once your domain’s DNS A record points to the server’s IP, secure it with a free SSL certificate via Let’s Encrypt:

    sudo apt install certbot python3-certbot-nginx -y
    sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

    Certbot automatically updates your Nginx config to redirect HTTP to HTTPS and sets up auto-renewal.

    sudo certbot renew --dry-run

    Wrapping Up

    At this point, you have a working Nginx server, a configured server block for your domain, and a valid SSL certificate. This is the same foundation we use for hosting our own WordPress and Magento environments.

    Next up in this series: hardening this server — SSH key auth, firewall rules, fail2ban, and other security parameters to lock things down before going live.

    Need help setting up or migrating your store to a production-ready server? Get in touch with our team — server setup and store migration is one of our core services.

  • Initial Ubuntu Server Setup: Users, SSH, and a Firewall

    Before Nginx, before Magento, before anything else goes on a server — the server itself needs to be locked down. A fresh Ubuntu box from most providers boots with a single root account, password authentication over SSH, and no firewall. That’s fine for the first few minutes after it boots, and a bad idea for every minute after that: the moment a server has a public IP, automated bots start trying root/password combinations against it within minutes.

    This is the exact setup we run on every server before installing anything user-facing on it — the same process we followed before setting up Nginx on our own production box. It’s written so you can follow it top to bottom on a brand-new server with no prior hardening experience; each step explains what it does and why, and includes how to check it actually worked before moving on.

    What you’ll need

    • A fresh server running Ubuntu 24.04 or 26.04 LTS (26.04 “Resolute Raccoon” is the current release; 24.04 is still fully supported and this guide applies equally to both), with a public IP address
    • The server’s root password or an initial SSH key, from whoever provisioned it (your hosting provider’s dashboard usually shows this once, right after the server is created)
    • A terminal on your own computer — Terminal on macOS/Linux, or Windows Terminal / PowerShell / WSL on Windows all work fine, since they all ship a modern SSH client
    • About 30–45 minutes, and access to your hosting provider’s web console as a fallback (explained in Troubleshooting below) in case an SSH change locks you out

    You do not need an SSH key pair already generated — Step 3 covers creating one if you don’t have one yet.

    Step 1: Connect to the Server

    From your own terminal, connect as root using the IP address your provider gave you:

    ssh root@your-server-ip

    The first time you connect to a given server, SSH will show a fingerprint and ask whether to continue connecting. This is normal for a first connection — type yes and press Enter. You’ll then be asked for the root password (or, if your provider set you up with a key already, you’ll be logged in directly).

    If the connection is refused or times out entirely, double-check the IP address in your provider’s dashboard and confirm the server has finished booting — a server can take a minute or two after creation before SSH is actually listening.

    Step 2: Update the System

    Before changing any configuration, bring the package list and installed packages up to date:

    apt update && apt upgrade -y

    apt update refreshes the list of available package versions; apt upgrade -y actually installs any newer versions, answering “yes” automatically so it doesn’t stop and wait for input. This can take a few minutes on a brand-new server. If it finishes by telling you a reboot is required (you’ll see a message about it, or a file at /var/run/reboot-required), reboot before continuing:

    reboot

    Give it a minute, then reconnect with the same ssh root@your-server-ip command as before.

    Step 3: Create a Non-Root User

    Working as root day to day is how one typo or one compromised session takes down an entire server. Create a regular user and grant it admin rights through sudo instead, so day-to-day work happens as a normal account that has to explicitly opt into elevated commands:

    adduser deploy

    Replace deploy with whatever username you actually want to use — it’ll be what you type before @your-server-ip from now on. adduser will ask you to set and confirm a password, then prompt for full name, room number, and so on — all of that is optional; you can press Enter through each one, then Y to confirm at the end.

    Now add that user to the sudo group, which is what grants permission to run commands as root via sudo:

    usermod -aG sudo deploy

    Verify the group actually took:

    groups deploy

    The output should list sudo alongside deploy. If it doesn’t, re-run the usermod command — a typo in the username is the usual cause.

    Step 4: Set Up SSH Key Authentication

    Passwords can be guessed or brute-forced; SSH keys can’t be, practically speaking. This step gets your new user logging in with a key instead of a password, which is what makes it safe to disable password login entirely in Step 6.

    If you don’t already have an SSH key pair on the computer you’re connecting from, generate one there — not on the server. Open a new terminal window on your own machine (leave the server session open) and run:

    ssh-keygen -t ed25519 -C "[email protected]"

    Press Enter to accept the default file location, and optionally set a passphrase (recommended, but not required). This creates two files: id_ed25519 (your private key — never share this) and id_ed25519.pub (your public key — safe to share, this is what goes on servers).

    Now copy your public key to the server, still from your own machine:

    ssh-copy-id deploy@your-server-ip

    It’ll ask for the deploy user’s password (the one you set in Step 3) one last time, then install your public key for that user automatically.

    If ssh-copy-id isn’t available (it’s missing on some minimal setups, including stock Windows), do the same thing manually. On your own machine, print your public key:

    cat ~/.ssh/id_ed25519.pub

    Copy the single line it prints. Then, in your existing root session on the server, run:

    mkdir -p /home/deploy/.ssh
    echo "paste-your-public-key-here" >> /home/deploy/.ssh/authorized_keys
    chown -R deploy:deploy /home/deploy/.ssh
    chmod 700 /home/deploy/.ssh
    chmod 600 /home/deploy/.ssh/authorized_keys

    Those exact permissions matter — SSH silently refuses to use a key if the .ssh directory or authorized_keys file is writable by anyone other than the owner.

    Step 5: Test the New Login Before Changing Anything Else

    This is the step it’s tempting to skip and the one that saves you from getting locked out. Keep your current root session open, and in a brand-new terminal window, connect as your new user:

    ssh deploy@your-server-ip

    You should land in a shell without being asked for a password (only your key’s passphrase, if you set one — that’s checked locally on your own machine, not by the server). Then confirm sudo actually works:

    sudo whoami

    It’ll ask for the deploy user’s password (not root’s) and should print root. If either of those doesn’t work, do not close your original root session — go back and re-check Steps 3 and 4 using that still-open root connection as your way in.

    Step 6: Lock Down SSH Itself

    With key-based login for your new user confirmed working, disable the two things that account for almost all automated SSH attacks: logging in as root directly, and logging in with a password at all. Edit the SSH daemon’s config (using either your root session or your new sudo user with sudo nano):

    nano /etc/ssh/sshd_config

    Find these two settings (they may already exist commented out with a #, or not exist at all) and set them exactly like this:

    PermitRootLogin no
    PasswordAuthentication no

    Save and exit (in nano: Ctrl+O, Enter, then Ctrl+X). Before restarting SSH, check the config file for syntax errors — a typo here can lock out every route into the server:

    sshd -t

    No output means the syntax is valid. If it prints an error, it’ll name the line number — go back into the file and fix it before continuing. Once it’s clean, apply the change:

    systemctl restart ssh

    Don’t close your current sessions yet. Open one more new terminal window and test a fresh connection:

    ssh deploy@your-server-ip

    That should still work exactly as before. Then confirm root login is actually blocked:

    ssh root@your-server-ip

    This should now be refused (Permission denied) rather than prompting for a password. Once both checks pass, it’s safe to close your original root session — from here on, the server is only reachable as your non-root user, with a key.

    Step 7: Set Up a Basic Firewall

    Ubuntu ships with ufw (Uncomplicated Firewall) pre-installed — it just isn’t turned on by default. Enabling it blocks every incoming port except the ones you explicitly allow. Allow SSH before turning it on, or you’ll cut off your own access:

    sudo ufw allow OpenSSH
    sudo ufw enable

    It’ll warn that this may disrupt existing SSH connections — type y to continue; the rule you just added covers this. Confirm the firewall is active and only SSH is open:

    sudo ufw status verbose

    You should see Status: active and an OpenSSH rule allowing incoming connections, with default policies of deny (incoming) and allow (outgoing). Everything else stays closed until you open it deliberately — for example, once a web server is installed, you’d run sudo ufw allow 'Nginx Full' to open ports 80 and 443.

    Step 8: Install fail2ban

    fail2ban watches log files for repeated failed login attempts and temporarily bans the source IP at the firewall level — a second layer that catches the (much smaller) set of attempts that get past key-only SSH, and slows down anything trying to brute-force other services later.

    sudo apt install fail2ban -y
    sudo systemctl enable --now fail2ban

    Confirm it’s running and watching SSH:

    sudo fail2ban-client status
    sudo fail2ban-client status sshd

    The default configuration (a few failed attempts within 10 minutes triggers a 10-minute ban) is reasonable to leave as-is. If you ever ban your own IP by mistake — typing your own password wrong a few times counts — unban it with:

    sudo fail2ban-client set sshd unbanip your-ip-address

    Custom settings (ban duration, retry limits, which services to watch) go in /etc/fail2ban/jail.local, which you create yourself rather than editing the default config directly — that keeps your changes intact across package updates. Worth revisiting once other services, like a web server, are running.

    Step 9: Turn On Automatic Security Updates

    So security patches land even on the days nobody’s watching:

    sudo apt install unattended-upgrades -y
    sudo dpkg-reconfigure --priority=low unattended-upgrades

    Choose “Yes” when the prompt appears. This installs security patches automatically as they’re released, without touching full package version upgrades — it won’t surprise you with a major version bump of something you’re relying on. You can check it’s active with:

    cat /etc/apt/apt.conf.d/20auto-upgrades

    Both lines in that file should end in "1".

    Step 10: Set the Hostname and Timezone

    Small, but it makes logs, cron output, and certificate/log timestamps far easier to reason about later — especially once you’re comparing timestamps across a server, an application, and your own machine:

    sudo hostnamectl set-hostname your-server-name
    sudo timedatectl set-timezone UTC

    UTC is the sane default for a server regardless of where you or your users are — it sidesteps daylight saving shifts entirely and keeps log timestamps unambiguous. Confirm both took effect:

    hostnamectl
    timedatectl

    Optional: Add Swap Space

    If this is a smaller server (1–2GB of RAM is common on entry-level plans), a swap file gives the kernel somewhere to fall back to under memory pressure instead of killing processes outright. Check whether you already have any:

    sudo swapon --show
    free -h

    If that comes back empty, create a 2GB swap file:

    sudo fallocate -l 2G /swapfile
    sudo chmod 600 /swapfile
    sudo mkswap /swapfile
    sudo swapon /swapfile

    Make it permanent so it survives a reboot, by adding it to /etc/fstab:

    echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

    Skip this step entirely on a server with plenty of RAM already — swap on an already memory-rich box mostly just masks problems you’d rather notice.

    Optional: Change the Default SSH Port

    Automated bots scan the entire internet for port 22 constantly — moving SSH to a different port doesn’t make the server more secure against a targeted attack (a port scan finds it in seconds), but it does cut out almost all of that background noise from your logs, which makes anything worth actually looking at easier to spot. It’s a convenience, not a substitute for the steps above.

    If you want it, edit /etc/ssh/sshd_config again and add or change:

    Port 2222

    Pick any unused port above 1024. Before restarting SSH, open the new port in the firewall and only then remove the old rule — in that order, so you’re never without a working path in:

    sudo ufw allow 2222/tcp
    sudo sshd -t
    sudo systemctl restart ssh

    Test a fresh connection on the new port before closing your current session or removing the old firewall rule:

    ssh deploy@your-server-ip -p 2222

    Once that works, remove the now-unneeded rule for port 22:

    sudo ufw delete allow OpenSSH

    Every command from here on out needs -p 2222 added to it (or an entry in your own machine’s ~/.ssh/config so you don’t have to remember).

    Checklist

    Before installing anything else, you should be able to say yes to all of these:

    • System packages are fully updated
    • A non-root user exists with working sudo access
    • That user logs in with an SSH key, no password prompt
    • ssh root@your-server-ip is refused
    • sudo ufw status verbose shows active, with only the ports you actually need open
    • fail2ban is running and watching SSH
    • Automatic security updates are enabled
    • Hostname and timezone are set

    Troubleshooting

    Locked out over SSH entirely. Almost every hosting provider (DigitalOcean, Linode, Hetzner, AWS Lightsail, and so on) offers a browser-based console into the server — look for “Console” or “VNC” in your server’s dashboard. This connects you directly, bypassing SSH entirely, so you can log in as root there and fix whatever broke (usually a typo in sshd_config, or PasswordAuthentication no applied before a key was actually working).

    “Permission denied (publickey)” when connecting as your new user. Almost always a permissions or path issue — re-check that ~/.ssh is 700 and ~/.ssh/authorized_keys is 600, both owned by that user (not root), and that you copied the .pub file’s contents, not the private key.

    Banned your own IP with fail2ban. Use the fail2ban-client set sshd unbanip command from Step 8, run from a working session (your provider’s console works here too if SSH itself is what’s banned).

    Forgot which user has sudo. getent group sudo (as root, via the console if needed) lists every user in the sudo group.

    Wrapping Up

    At this point the server has a non-root sudo user, key-only SSH access with root login disabled, a default-deny firewall with only SSH open, brute-force protection, automatic security patching, and a sane hostname and timezone. That’s the baseline we put on every server before installing anything user-facing on top of it.

    If your hosting provider supports it, this is also a good point to take a snapshot or backup image of the server — it gives you a clean, hardened starting point to restore from if a later change ever goes badly wrong, rather than repeating all of the above from scratch.

    Next up: installing and configuring Nginx on top of this.

    Need help setting up or migrating your store to a production-ready server? Get in touch with our team — server setup and store migration is one of our core services.

  • How to Install Ubuntu Server on a New Machine

    Every server we run starts the same way: a blank machine, a USB drive, and the Ubuntu Server installer. This is the step before the step before Nginx — before you can harden a fresh server or install anything on it, Ubuntu itself has to actually be on the machine. This guide covers exactly that: downloading the installer, writing it to a USB drive, and getting through the installation screens, on real hardware or a spare machine you’re setting up yourself.

    If you’re provisioning a cloud VPS instead (DigitalOcean, Linode, AWS Lightsail, and similar), the provider usually images the disk with Ubuntu for you and this whole guide doesn’t apply — skip straight to our post on initial server setup once the box boots. This one is for installing Ubuntu yourself, from scratch, on physical or virtual hardware you control directly.

    What you’ll need

    • A USB drive, at least 4GB, that you don’t mind erasing completely
    • Another computer to download the installer and write the USB drive from
    • The target machine — the one Ubuntu is actually being installed on — with a monitor and keyboard attached, or remote access to its BIOS/IPMI console if it’s headless
    • A wired network connection for the target machine, if possible — the installer can configure Wi-Fi, but wired is simpler and more reliable during setup
    • About 20–30 minutes, most of it unattended once the install actually starts

    This will erase everything on the target machine’s disk. Back up anything on it you want to keep before starting Step 9.

    Step 1: Download the Ubuntu Server ISO

    Go to ubuntu.com/download/server and download the latest LTS release — currently Ubuntu 26.04 LTS (“Resolute Raccoon”). LTS releases get five years of security updates, which is what you want on anything running unattended; skip the interim (non-LTS) releases for a server. The file you’re looking for is named something like:

    ubuntu-26.04-live-server-amd64.iso

    It’s a couple of gigabytes, so this is the slowest part of the whole process on a typical connection.

    Step 2: Verify the Download

    Worth the extra minute, especially over a slow or flaky connection — a corrupted ISO tends to fail partway through installation rather than up front, which wastes far more time than checking now. Ubuntu publishes checksums alongside every release. Download the checksum file from the same release page (or directly, replacing the version if needed):

    curl -O https://releases.ubuntu.com/26.04/SHA256SUMS

    Then, from the same directory as the ISO, check it:

    sha256sum -c SHA256SUMS --ignore-missing

    You’re looking for a line that says ubuntu-26.04-live-server-amd64.iso: OK. If it says FAILED instead, delete the ISO and download it again — don’t install from a file that fails this check.

    Step 3: Create a Bootable USB Drive

    Plug in the USB drive and write the ISO to it. Whichever tool you use, this erases everything currently on the drive.

    On Windows: use Rufus. Open it, select the ISO, select your USB drive, and click Start with the default settings.

    On macOS or Windows (GUI option): use balenaEtcher — select the ISO, select the drive, click Flash, and confirm.

    On Linux (or from a terminal anywhere that has dd), first find the correct device name for your USB drive — this matters, since writing to the wrong device can destroy data on the wrong disk:

    lsblk

    Identify your USB drive by its size in the list (for example /dev/sdb), then write the ISO to the whole disk, not a partition on it:

    sudo dd if=ubuntu-26.04-live-server-amd64.iso of=/dev/sdb bs=4M status=progress conv=fsync

    Double-check the device name in of= before pressing Enter. dd doesn’t ask for confirmation and doesn’t care if you point it at the wrong disk.

    Step 4: Boot From the USB Drive

    Plug the USB drive into the target machine and power it on, then get into its boot menu — the key varies by manufacturer, but it’s almost always one of F12, F10, F2, Del, or Esc, pressed repeatedly right after the machine powers on, before the OS would normally start loading. Select the USB drive from the boot menu.

    If the machine boots straight past the USB drive into whatever’s already installed, go into the BIOS/UEFI settings instead (usually the same keys) and either change the boot order to put USB first, or disable Secure Boot if it’s blocking the installer from starting — Ubuntu’s installer is signed and normally works fine with Secure Boot on, but some older firmware doesn’t recognize it correctly.

    You’ll land on Ubuntu’s installer welcome screen — a full-screen, keyboard-navigated text interface (this is normal for Server; there’s no mouse involved anywhere in this process). Use the arrow keys to move, Enter to select, and Space to check/uncheck boxes throughout.

    Step 5: Language and Keyboard Layout

    First screen: pick the installer’s display language. This only affects the installer itself, not anything about how the finished server runs. Next, keyboard layout — the installer can auto-detect it by asking you to press a few keys, or you can pick it manually from the list. Get this right; it’s easy to overlook and annoying to have wrong once you’re typing passwords blind at a login prompt later.

    Step 6: Choose the Installation Type

    You’ll be offered Ubuntu Server (the standard install) or Ubuntu Server (minimized). The minimized option strips out packages that a typical server doesn’t need (things like documentation and some default utilities), resulting in a smaller footprint and a slightly faster boot. Either works fine for what comes next in this series — we use the standard install, since the space saved rarely matters and it’s one less thing to think about if you need a common utility later.

    Step 7: Network Configuration

    The installer detects available network interfaces and, if a cable is plugged in, usually configures one automatically via DHCP — you’ll see it listed with an IP address already assigned. That’s fine to accept as-is for now; it’s simpler to set a static IP or DHCP reservation from your router once the server is up and you know its hardware address, rather than fighting with it here. If nothing is detected, double-check the network cable and that the right interface is selected, then continue.

    Step 8: Proxy and Mirror

    Two short screens, both fine to leave alone in almost every home or small-office setup. The proxy field is only relevant if your network requires one to reach the internet — leave it blank otherwise. The mirror screen picks which Ubuntu package server to download from during installation and afterward; the installer tests the default automatically and will warn you if it can’t reach it. Just continue if the test passes.

    Step 9: Partition the Disk

    This is the step that erases the disk, so make sure anything you need is backed up before continuing. For a first server, “Use an entire disk” is the right choice — the installer handles the partitioning for you. You’ll also see options to set up LVM (makes resizing partitions later much easier) and to encrypt the disk with LUKS (protects the data at rest if the physical drive is ever lost or stolen, at the cost of needing a passphrase on every boot). Turning LVM on is a reasonable default; encryption is worth it for anything holding sensitive data but adds a step to every reboot, so skip it if the server needs to come back up unattended after a power loss.

    The next screen shows the exact partition layout that’s about to be created, as a final review. Confirm it, and the installer will warn you one more time before it actually writes anything — that’s your last chance to back out.

    Step 10: Set Up Your Profile

    Enter your name (used for display only), a name for the server itself (its hostname — keep it short, lowercase, no spaces), a username, and a password. This account is created with sudo access automatically — there’s no separate root password to set here, which is exactly the non-root-by-default setup our server hardening guide recommends anyway.

    Step 11: Skip Ubuntu Pro (For Now)

    You’ll be offered the chance to attach an Ubuntu Pro subscription, which adds extended security maintenance and a few compliance-focused tools. It’s free for personal use up to a handful of machines, but not something you need to decide on right now — skip it and attach it later with sudo pro attach if you ever want it.

    Step 12: Install OpenSSH Server

    This is the one screen on this list worth stopping for. Check the box to install OpenSSH server — without it, the only way to reach this machine afterward is with a monitor and keyboard physically attached to it, which defeats the point of a server.

    The installer also offers to import your SSH public key directly from GitHub or Launchpad by username, which pre-populates key-based login before the server even finishes installing. If you already have keys set up there, it’s a genuine shortcut and worth using. Otherwise, leave it and follow the SSH key setup steps in our initial server setup guide once the install is done — either way, don’t enable password authentication over SSH here if you’re offered the choice; leave it off.

    Step 13: Skip Featured Server Snaps (For Now)

    The last screen offers a checklist of common server software — Docker, Kubernetes tooling, and similar — installable right now as snaps. Skip all of them for a first install. It’s easier to install exactly what a given server needs later, deliberately, than to start from a machine with software on it you didn’t choose and now have to account for.

    Step 14: Let It Install, Then Reboot

    From here it’s unattended — the installer copies files and configures the system while showing a running log, which usually takes 5–10 minutes depending on the machine and network speed. When it finishes, you’ll see “Reboot Now.” Select it, and when prompted, remove the USB drive and press Enter to continue — this step exists specifically so the machine doesn’t just boot straight back into the installer.

    Step 15: First Login

    After rebooting, you’ll get a text login prompt directly on the machine — log in with the username and password from Step 10 to confirm it worked. From here, everything else happens over SSH from your own computer instead. Find the server’s IP address (it’s shown right on that login screen, or check your router’s connected-devices list), then connect from your own terminal:

    ssh your-username@server-ip

    Checklist

    • The ISO checksum matched before you wrote it to USB
    • The machine booted the installer from USB, not its existing disk
    • OpenSSH server was installed during setup
    • You can log in locally on the machine with your username and password
    • You can also connect to it over SSH from your own computer, using its IP address

    Troubleshooting

    The boot menu never appears, or it boots straight into the old OS. You’re either missing the boot-menu key window (it’s brief — try tapping the key repeatedly starting the instant you power the machine on) or the firmware needs the boot order changed in BIOS/UEFI settings directly, as described in Step 4.

    The installer won’t start, or the screen stays black after selecting the USB drive. Usually a bad USB write — re-run Step 3, ideally with a different USB drive if one’s available, and re-verify the checksum from Step 2 first.

    No network detected during installation. Confirm the cable is actually connected and the correct interface is selected on the network screen; if you’re relying on Wi-Fi, some adapters aren’t supported by the installer’s built-in drivers and need a wired connection for the initial install instead.

    Can’t connect over SSH after rebooting. Confirm you actually checked the “install OpenSSH server” box in Step 12 — if you missed it, log in locally on the machine and install it manually: sudo apt update && sudo apt install openssh-server -y.

    Wrapping Up

    At this point you have a plain Ubuntu Server install, reachable over SSH, with nothing else on it yet — which is exactly where you want to be before locking it down. From here, follow our initial server setup guide to create a proper non-root user, switch to key-only SSH, and turn on a firewall, then our guide on installing Nginx once the server itself is hardened.

    Need help setting up or migrating your store to a production-ready server? Get in touch with our team — server setup and store migration is one of our core services.