November 5, 2026
Setting Up a Single-Site LAMP Server on Ubuntu with HTTPS and SSH Keys
A practical foundation for a single-site Ubuntu web server.

By Cezar Cazan
10 min read
A practical foundation for a single-site Ubuntu web server.
A working web server needs more than a successful package installation. Apache must serve the correct directory, PHP must execute rather than download, MySQL accounts need the right scope, certificates must match the address visitors use, and backups must actually be restorable.
This guide turns a real installation and troubleshooting conversation into a repeatable procedure. For this use case, the server needs to host a web app, accessible inside its network and on a public ip, with a specific port.
You can do this in multiple ways, this is just one way you could do it.
Step A: Choose and Install the Ubuntu release
As of the update date, Ubuntu 26.10 is the latest interim release (last verified on Nov 5th, 2026); Ubuntu 26.04 LTS is the current long-term-support series. For a server intended to run for years, this guide targets a fully updated Ubuntu Server 26.04 LTS, including installations made from 26.04.1 media. Its official repositories provide PHP 8.5. The unversioned package names below select the distribution-supported versions rather than introducing an external PHP repository. See Canonical's Ubuntu 26.10 announcement and the Ubuntu PHP package index.
If using 26.10, check package candidates and application compatibility first. Do not assume its MySQL major version or PHP patch version matches the LTS release. "Latest supported by Ubuntu" and "latest upstream release" are different choices.
You need an installed Ubuntu server, a normal account with sudo, and console or recovery access before changing SSH or firewall rules. Commands are entered on the Ubuntu server unless explicitly labeled as client commands. Run SQL statements inside the MySQL prompt, and paste configuration examples into the named files rather than into the shell.
Remember to install and enable the Openssh-server (usually the last part of the install process). You will need it to be able to login remotely for the next steps.
Note: a server requires a stable IP address. It's common practice to reserve a static IP based on the server's mac address. This way, the IP will not change randomly after restart or power failures.
Step B: System Update & Core Stack Installation
After installing the base software, connect to your server and ensure all repository indexes and system packages are fully up to date. Replace the below user/ip with your actual ones. Note the port: 22 is the default ssh port.
ssh -p 22 your-user@192.168.1.111
sudo apt update && sudo apt upgrade -yssh -p 22 your-user@192.168.1.111
sudo apt update && sudo apt upgrade -yB1. Install Apache Web Server
Install the web server and configure the local Uncomplicated Firewall (UFW) to allow both HTTP (80) and HTTPS (443) traffic:
sudo apt install apache2 -y
sudo ufw allow 22/tcp
sudo ufw allow 'Apache Full'
sudo ufw enable
sudo ufw status verbosesudo apt install apache2 -y
sudo ufw allow 22/tcp
sudo ufw allow 'Apache Full'
sudo ufw enable
sudo ufw status verboseKeep the existing SSH connection open and test another connection. Later, allow the new SSH port before changing the listener.
B2. Install MySQL Database Server
Install the database server and run the native security script to eliminate vulnerable default settings:
sudo apt install mysql-server -y
sudo mysql_secure_installationsudo apt install mysql-server -y
sudo mysql_secure_installationPro Tip: When prompted, enable the VALIDATE PASSWORD COMPONENT. Select Level 1 (Medium) or higher. This enforces strict password policies (minimum 8 characters, requiring uppercase, lowercase, numbers, and special characters).
Ubuntu's MySQL package normally configures local root access using auth_socket. This lets an administrator connect with sudo mysql without a separate MySQL root password. Keep this default for this guide; the application will use its own password-authenticated account.
sudo mysqlsudo mysqlIf root was previously changed to password authentication, connect with mysql -u root -p instead:
mysql -u root -pmysql -u root -pB3. Install PHP with Core Extensions
Ubuntu includes native support for modern PHP. Install the base binary along with the core extensions required for database connectivity, encryption, and media handling:
sudo apt install php libapache2-mod-php php-mysql php-curl php-gd php-xml php-mbstring php-zip -ysudo apt install php libapache2-mod-php php-mysql php-curl php-gd php-xml php-mbstring php-zip -yStep C: Custom Long-Term SSL Setup for Internal & Public IPs
Public IP access does not require a self-signed certificate. Let's Encrypt supports publicly trusted public-IP certificates when its validation requirements can be met. For private LAN addresses, use a trusted internal CA or a deliberately trusted self-signed certificate. This solution proposes a Self-Signed SSL Certificate generated with a 10-year validity.
This example uses a long-lived self-signed certificate for a controlled environment. Its lifetime does not provide automatic renewal. If the key is compromised or an IP address changes, replace the certificate and update client trust as necessary. For managed clients, an internal CA provides a more maintainable trust arrangement.
C1. Generating the self-signed certificate
To make the certificate match both the server's LAN and public IP addresses, include those addresses as Subject Alternative Names (SANs). SANs prevent address-mismatch errors; they do not make a self-signed issuer trusted. Clients must also explicitly trust the verified certificate or its issuing CA.
Clients need both:
- A certificate containing the address they use.
- A trusted issuer or an explicitly verified trust arrangement.
Run the following command to generate the private key and certificate:
sudo openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
-keyout /etc/ssl/private/apache-selfsigned.key \
-out /etc/ssl/certs/apache-selfsigned.crt \
-subj "/CN=YOUR_LOCAL_IP" \
-addext "basicConstraints = CA:FALSE" \
-addext "keyUsage = digitalSignature, keyEncipherment" \
-addext "extendedKeyUsage = serverAuth" \
-addext "subjectAltName = IP:YOUR_LOCAL_IP, IP:YOUR_PUBLIC_IP, IP:127.0.0.1"sudo openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
-keyout /etc/ssl/private/apache-selfsigned.key \
-out /etc/ssl/certs/apache-selfsigned.crt \
-subj "/CN=YOUR_LOCAL_IP" \
-addext "basicConstraints = CA:FALSE" \
-addext "keyUsage = digitalSignature, keyEncipherment" \
-addext "extendedKeyUsage = serverAuth" \
-addext "subjectAltName = IP:YOUR_LOCAL_IP, IP:YOUR_PUBLIC_IP, IP:127.0.0.1"(Replace YOUR_LOCAL_IP with the server's internal LAN IP, e.g., 192.168.1.111, and YOUR_PUBLIC_IP with your external internet IP).
Remember to set the right permissions afterwards:
sudo chmod 0600 /etc/ssl/private/apache-selfsigned.key
sudo chmod 0644 /etc/ssl/certs/apache-selfsigned.crtsudo chmod 0600 /etc/ssl/private/apache-selfsigned.key
sudo chmod 0644 /etc/ssl/certs/apache-selfsigned.crtC2. Configuring the Apache Virtual Host
Create a new secure configuration file for your site:
sudo nano /etc/apache2/sites-available/site-secure-ssl.confsudo nano /etc/apache2/sites-available/site-secure-ssl.confPaste the following configuration. This block handles the SSL certificates, maps the server identities, and explicitly enables .htaccess override rules:
<VirtualHost *:443>
ServerName YOUR_LOCAL_IP
ServerAlias YOUR_PUBLIC_IP
DocumentRoot /var/www/html
SSLEngine on
SSLCertificateFile /etc/ssl/certs/apache-selfsigned.crt
SSLCertificateKeyFile /etc/ssl/private/apache-selfsigned.key
<Directory /var/www/html>
DirectoryIndex index.php index.html
Options -Indexes +FollowSymLinks
AllowOverride FileInfo
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/site_ssl_error.log
CustomLog ${APACHE_LOG_DIR}/site_ssl_access.log combined
</VirtualHost><VirtualHost *:443>
ServerName YOUR_LOCAL_IP
ServerAlias YOUR_PUBLIC_IP
DocumentRoot /var/www/html
SSLEngine on
SSLCertificateFile /etc/ssl/certs/apache-selfsigned.crt
SSLCertificateKeyFile /etc/ssl/private/apache-selfsigned.key
<Directory /var/www/html>
DirectoryIndex index.php index.html
Options -Indexes +FollowSymLinks
AllowOverride FileInfo
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/site_ssl_error.log
CustomLog ${APACHE_LOG_DIR}/site_ssl_access.log combined
</VirtualHost>Some applications require additional override categories. Follow the application's documentation and enable only what it needs.
C3. Enable the New Configuration
Activate the SSL and rewrite modules, enable your new configuration, and restart the web server:
sudo a2enmod ssl rewrite
sudo a2ensite site-secure-ssl.conf
sudo apache2ctl configtest && sudo systemctl reload apache2sudo a2enmod ssl rewrite
sudo a2ensite site-secure-ssl.conf
sudo apache2ctl configtest && sudo systemctl reload apache2SUCCESS!
You can now access your web server in your favorite browser, by typing https://YOUR_LOCAL_IP/
(Replace YOUR_LOCAL_IP with the server's internal LAN IP, e.g., 192.168.1.111)
A self-signed certificate will produce a browser trust warning until the client explicitly trusts it. Before trusting it, verify its SHA-256 fingerprint through a trusted channel. Do not bypass an unexpected warning. Once trust and address matching are correct, the page should load over HTTPS.
Next, inspect Apache's configuration, service status, and listening ports. These checks do not yet prove that PHP executes or that the application can connect to MySQL.
openssl x509 -in /etc/ssl/certs/apache-selfsigned.crt \
-noout -sha256 -fingerprint
sudo apache2ctl configtest
sudo apache2ctl -S
sudo systemctl status apache2 mysql --no-pager
sudo ss -ltnpopenssl x509 -in /etc/ssl/certs/apache-selfsigned.crt \
-noout -sha256 -fingerprint
sudo apache2ctl configtest
sudo apache2ctl -S
sudo systemctl status apache2 mysql --no-pager
sudo ss -ltnpStep D: Web Directory Permissions & PHP Fixes
To prevent Permission Denied (Error Code 3) issues when uploading or renaming files via SFTP (FileZilla/WinSCP) while keeping Apache happy, adjust the directory ownership:
sudo chown -R user:www-data /var/www/html
sudo find /var/www/html -type d -exec chmod 2750 {} +
sudo find /var/www/html -type f -exec chmod 0640 {} +sudo chown -R user:www-data /var/www/html
sudo find /var/www/html -type d -exec chmod 2750 {} +
sudo find /var/www/html -type f -exec chmod 0640 {} +(Replace user with your actual user)
Apache can read application code but cannot modify it with these permissions. Grant write access only to the specific cache, session, or upload directories required by the application. Ensure newly uploaded files remain group-readable, and preserve executable permissions on application scripts that need them.
Add a temporary PHP test. Run it as the deployment account:
printf '%s\n' '<?php echo "PHP execution OK";' > /var/www/html/php-check.php
chmod 0640 /var/www/html/php-check.phpprintf '%s\n' '<?php echo "PHP execution OK";' > /var/www/html/php-check.php
chmod 0640 /var/www/html/php-check.phpOpen
https://YOUR_LOCAL_IP/php-check.php. It should display "PHP execution OK", not PHP source code or a download. Remove the diagnostic immediately afterward:
rm /var/www/html/php-check.phprm /var/www/html/php-check.phpFixing PHP JIT & Security Warnings
Modern PHP environments can throw JIT memory allocation warnings due to strict kernel security restrictions. Fix this by opening your php.ini files:
sudo nano /etc/php/8.5/apache2/php.ini
sudo nano /etc/php/8.5/cli/php.inisudo nano /etc/php/8.5/apache2/php.ini
sudo nano /etc/php/8.5/cli/php.iniSearch for pcre.jit (or add it at the bottom) and set it to zero if you get warnings in the error log. If you see a ";" before the instruction, that means the line is commented, and you need to uncomment it by removing the ";":
pcre.jit=0pcre.jit=0Restart Apache to apply: sudo systemctl restart apache2.
Step E: Upload and Configure your Web App
It's time to copy the files on the server and import the database. Easiest method is via SFTP (Filezilla or WinSCP). Note: Default web root is in /var/www/html
For the MySQL part, I recommend you create a new MySQL user and database, and grant permissions to the user only for that new database.
sudo mysql
CREATE DATABASE mynewdatabase;
CREATE USER 'newuser'@'localhost' IDENTIFIED BY 'REPLACE_WITH_A_UNIQUE_STRONG_PASSWORD';
GRANT ALL PRIVILEGES ON mynewdatabase.* TO 'newuser'@'localhost';
EXIT;sudo mysql
CREATE DATABASE mynewdatabase;
CREATE USER 'newuser'@'localhost' IDENTIFIED BY 'REPLACE_WITH_A_UNIQUE_STRONG_PASSWORD';
GRANT ALL PRIVILEGES ON mynewdatabase.* TO 'newuser'@'localhost';
EXIT;Replace the REPLACE_WITH_A_UNIQUE_STRONG_PASSWORD with a strong password. Configure the new MySQL credentials in your web app and import the database. You can install PhpMyAdmin or use the single file adminer to manage your database via a graphical interface.
Restrict database administration tools to a VPN or trusted management addresses. Keep MySQL listeners local and do not forward database ports 3306 or 33060 to the internet. A single-file administration tool still needs updates and access controls.
Upload the SQL file into a private directory outside /var/www/html. Back up existing data before importing, and inspect the dump for USE, CREATE DATABASE, and destructive statements.
However, you can greatly increase the import speed process by copying the sql file on the server and importing it via the command line. The second command checks the database account; the application must also be tested using its configured credentials:
mysql -u newuser -p mynewdatabase < sqlfile.sql
mysql -u newuser -p mynewdatabase \
-e 'SELECT DATABASE(), CURRENT_USER(); SHOW TABLES;'mysql -u newuser -p mynewdatabase < sqlfile.sql
mysql -u newuser -p mynewdatabase \
-e 'SELECT DATABASE(), CURRENT_USER(); SHOW TABLES;'Step F: Some SSH Hardening (Port & Key-Only)
Leaving SSH on default Port 22 with password authentication invites constant brute-force attacks. We will shift SSH to a custom Port (like 49100 for example) and enforce Key-Only Authentication.
F1. Allow the new SSH port in UFW
sudo ufw allow 49100/tcpsudo ufw allow 49100/tcpAllow the destination port used on the Ubuntu server. For example, if the router forwards public TCP port 9943 to the server's TCP port 443, Apache listens on 443 and UFW allows 443; visitors use https://PUBLIC_IP:9943/. Only allow 9943 on Ubuntu if Apache actually listens on 9943. Configure the corresponding router forwarding rule and test from outside the LAN.
The next steps are for preparing to enforce Key-Only Authentication: generate your SSH key pair, import it on the server and make the necessary changes to the config file.
F2. Generate and add your custom SSH Key
Use SSH public-key authentication and disable password authentication to prevent password-guessing logins. These instructions create ordinary SSH keys, not SSH certificates.
On your local client machine (Mac or Windows terminal), generate a high-security Ed25519 key pair:
ssh-keygen -t ed25519 -C "your_email@domain"ssh-keygen -t ed25519 -C "your_email@domain"Print the public key on your client screen: cat ~/.ssh/id_ed25519.pub. Copy the entire string starting with ssh-ed25519.
Back on the Ubuntu Server, open the authorized keys file:
mkdir -p ~/.ssh && chmod 700 ~/.ssh
nano ~/.ssh/authorized_keysmkdir -p ~/.ssh && chmod 700 ~/.ssh
nano ~/.ssh/authorized_keysPaste the public key on a single line, save it, and restrict permissions: chmod 600 ~/.ssh/authorized_keys
Keep the current session open. From a second client terminal, confirm that the new key works on the current SSH port:
ssh -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes \
-p 22 your-user@YOUR_LOCAL_IPssh -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes \
-p 22 your-user@YOUR_LOCAL_IPContinue only after this succeeds. Protect the private key with a passphrase and never copy it to the server.
F3. Change the default SSH Port, Enforce Key Authentication & Disable Passwords Globally
OpenSSH generally uses the first obtained value for a setting, so earlier included configuration can take precedence over later settings in the main file. Some cloud installations include 50-cloud-init.conf; it is not present on every server. Prefer an early local configuration snippet rather than relying on edits to generated cloud configuration.
sudo nano /etc/ssh/sshd_config.d/00-local-hardening.confsudo nano /etc/ssh/sshd_config.d/00-local-hardening.confPut the following settings in that file. If you previously added these settings to the main configuration, remove those duplicate entries.
Port 49100
UsePAM yes
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin noPort 49100
UsePAM yes
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin noValidate the configuration and inspect the effective settings before applying it:
sudo /usr/sbin/sshd -t
sudo /usr/sbin/sshd -T | grep -E \
'^(port|usepam|pubkeyauthentication|passwordauthentication|kbdinteractiveauthentication|permitrootlogin) 'sudo /usr/sbin/sshd -t
sudo /usr/sbin/sshd -T | grep -E \
'^(port|usepam|pubkeyauthentication|passwordauthentication|kbdinteractiveauthentication|permitrootlogin) 'If validation succeeds, apply the changes. Socket-activated installations also need their listener configuration refreshed:
sudo systemctl daemon-reload
if systemctl is-active --quiet ssh.socket; then
sudo systemctl restart ssh.socket
fi
sudo systemctl restart ssh.service
sudo ss -ltnpsudo systemctl daemon-reload
if systemctl is-active --quiet ssh.socket; then
sudo systemctl restart ssh.socket
fi
sudo systemctl restart ssh.service
sudo ss -ltnpConfirm that the intended port is listening. Existing local systemd overrides may require separate adjustment. Keep the original session open until a new connection succeeds.
From a second terminal, test key authentication on port 49100. Then explicitly test password-only authentication; that attempt must fail:
ssh -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes \
-p 49100 your-user@YOUR_LOCAL_IP
ssh -o PubkeyAuthentication=no \
-o PreferredAuthentications=password,keyboard-interactive \
-p 49100 your-user@YOUR_LOCAL_IPssh -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes \
-p 49100 your-user@YOUR_LOCAL_IP
ssh -o PubkeyAuthentication=no \
-o PreferredAuthentications=password,keyboard-interactive \
-p 49100 your-user@YOUR_LOCAL_IPA successful connection alone does not prove passwords are disabled, because an SSH agent may supply a key. Remove the old port's firewall rule or router mapping only after confirming the new connection works.
That's all for now. What's next?
These are the basic steps for a no-fuss setup. From here, you can add layers and services, enforce even more security features, and make your custom server work the way you want it to work.
Some ideas:
- mount external drives to increase capacity
- use cron jobs to schedule tasks
- automate backups and test restores
- write your own custom bash scripts
- install Fail2Ban
Just remember the most important thing: Security and ongoing maintenance
Configure automatic security updates and review the selected package origins:
sudo apt install unattended-upgrades
sudo dpkg-reconfigure unattended-upgrades
sudo unattended-upgrade --dry-run --debug
systemctl list-timers --all | grep -E 'apt-daily'sudo apt install unattended-upgrades
sudo dpkg-reconfigure unattended-upgrades
sudo unattended-upgrade --dry-run --debug
systemctl list-timers --all | grep -E 'apt-daily'Plan required reboots and application testing. Automatic OS updates do not automatically maintain every manually downloaded PHP application, CMS plugin or third-party tools. Verify any protection you choose to deploy and stay vigilant.
Happy coding and good luck with your project!