all topics

Cloud

Setting Up a Lightsail Web Server

From a blank AWS account to a LAMP server serving your domain over HTTPS — no shortcuts.

TLDR;

AWS Lightsail is Amazon’s fixed-price VPS offering — a pre-configured Linux instance with attached SSD storage, a static IP option, and a simplified firewall, all managed from a console that’s much less overwhelming than the full AWS Management Console. Unlike EC2, there are no per-hour compute surprises and no separate load balancer or block-storage bills to track. You pick a plan, pay a flat monthly rate, and get a fully functional server.

This article walks through setting up a LAMP server — Linux, Apache, MariaDB, and PHP — on a fresh Lightsail instance, pointing a real domain at it, installing an SSL certificate, and setting up a local working directory you can deploy from. By the end you’ll have a server ready for content and a repeatable deploy workflow. Later articles cover adding database-backed PHP, Docker Compose for local testing, and setting up the same server as a private git host.

The Stack

Four-layer diagram inside a Lightsail instance bounding box. A Browser box on the left sends HTTPS requests on port 443 to Apache. Apache passes .php files to PHP 8.3. PHP queries MariaDB over localhost. Each box lists its role: Apache handles TLS, virtual hosts, rewriting, and static files; PHP handles sessions, dotenv, app logic, and HTML output; MariaDB stores user data and is accessible only from localhost.
All four layers run on a single Lightsail instance. MariaDB is only accessible from localhost — it never listens on a public port.
  • Linux (Ubuntu 24.04 LTS) — the OS. Lightsail provides a clean, pre-configured image with nothing extra installed.
  • Apache — the web server. Handles TLS termination, virtual hosts, URL rewriting, and static file delivery. PHP is wired in via mod_php.
  • MariaDB — a drop-in MySQL replacement. Stores application data. Configured to listen on localhost only.
  • PHP 8.3 — server-side scripting. Apache routes .php requests through mod_php; the response is rendered HTML.

What It Costs

Domain name:      $10–20/year at your registrar of choice
SSL certificate:  free (Let’s Encrypt / certbot) — or $10–100/year from a CA
Lightsail plan:   $3.50/month (512 MB RAM, 20 GB SSD) up to $160/month
                  The 2 GB RAM / 60 GB SSD / $10/month plan handles most small sites
Static IP:        free while attached to a running instance
                  $0.005/hour if unattached — release any IP you are not using
Start with a plan slightly larger than you think you need. Lightsail lets you upgrade (by snapshotting and relaunching from the snapshot), but you cannot downgrade a running instance in place. The $3.50 plan is tight for a LAMP stack under any real load.

Before You Start

Two steps need time and money to complete, and both run in parallel with everything else. Start them before touching AWS.

Register Your Domain

Purchase your domain at any registrar. DNS propagation after pointing your domain at a new IP can take up to 48 hours, though it’s usually much faster. Start the registration now so the delay runs concurrently with your server setup.

Obtain an SSL Certificate

Two paths:

  • Let’s Encrypt / certbot — free, automated, and renews every 90 days. The certbot tool issues a certificate against your live domain, so DNS must resolve to the server before it can issue. Instructions at certbot.eff.org. Certbot deposits the key and cert in /etc/letsencrypt/live/yoursite.com/.
  • Commercial CA — purchase from your registrar or a CA such as Sectigo or DigiCert. You receive a .crt, a .key, and usually an intermediate .pem chain file. The template workflow described below is built around this layout.

Create the Lightsail Instance

  1. Log in to the AWS Management Console and open Lightsail.
  2. Click Create instance.
  3. Choose the AWS Region closest to your users.
  4. Under Select a blueprint choose OS OnlyUbuntu 24.04 LTS.
  5. Under SSH key pair, click Download to save the default key file. Save this immediately. AWS does not store the private half. If you lose it you can only reset the pair — you cannot retrieve the original. Rename the downloaded file to yoursitename.pem.
  6. Choose an instance plan. The 2 GB RAM / $10 plan is a reasonable starting point for a LAMP site with moderate traffic.
  7. Give the instance a name and click Create instance.

Assign a Static IP

By default, Lightsail instance IPs change when the instance is stopped and restarted. A DNS A record pointing at a moving target stops working. Assign a static IP before you configure DNS:

  1. In the Lightsail console, open the Networking tab.
  2. Click Create static IP and attach it to your new instance.
  3. Each AWS account includes five static IPs at no charge while they are attached to a running instance. Detach and release any IP you stop using — an unattached static IP costs $0.005/hour.

Open the Firewall

In your instance’s Networking tab under IPv4 Firewall, ensure these ports are open:

Port 22    SSH     (open by default — keep it)
Port 80    HTTP    (open by default — Apache needs it for the HTTP→HTTPS redirect)
Port 443   HTTPS   (add this if not already present)
Port 3306  Custom  (MariaDB — only open this if you need remote DB access)
           Leave 3306 closed if all DB connections come from the server itself.

Point Your Domain

At your domain registrar’s DNS control panel, add an A record:

Type:  A
Name:  @          (root domain, or blank depending on your registrar)
Value: 1.2.3.4    (your Lightsail static IP)
TTL:   3600

Add a second A record for www pointing to the same IP if you want www.yoursite.com to resolve as well. While you wait for DNS to propagate, you can SSH directly to the IP address.

Create Your Local Working Directory

All development and deployment work happens from a local working directory on your machine. This directory holds your site files, deploy scripts, Apache configuration, and the SSH key. A template and a creation script set it up in one step.

Download websitetools.zip

Extract the archive. It contains a template/ directory and two creation scripts. The scripts expect to be run from the same directory that contains template/.

Run create-site

From the directory where you extracted the archive, run the script for your platform and pass it an install path and your site name:

# Windows
create-site.bat C:\sites mysitename

# Linux / macOS
./create-site.sh /home/user/sites mysitename

The script clones the template directory into <install-path>/mysitename/, replacing every occurrence of the word template — in directory names, file names, and file contents — with your site name. It asks for confirmation before creating anything and refuses to overwrite an existing directory.

What Gets Created

mysitename/
├── mysitename.pem             ← copy your AWS .pem key here after running the script
├── mysitename.url             ← browser shortcut to your site
├── mysitename-ssh.bat         ← SSH into the server
├── uploadall.bat              ← SCP everything and run refresh.sh
├── uploadcontent.bat          ← SCP docroot only
├── uploadcontent-web.bat      ← SCP docroot, skip Docker / Composer files
├── database/
│   └── gitadd.txt
└── website/
    ├── apache2/
    │   ├── mysitename.conf        ← HTTP vhost config
    │   ├── mysitename-ssl.conf    ← HTTPS vhost (redirects HTTP, terminates TLS)
    │   ├── setup.sh               ← enable HTTP vhost (first run)
    │   ├── update.sh              ← re-deploy HTTP vhost
    │   └── update-ssl.sh          ← switch to HTTPS-only vhost
    ├── csr/
    │   ├── copythem.sh            ← install certs into /etc/ssl/
    │   ├── match.bat              ← verify key/cert match (Windows)
    │   └── match.sh               ← verify key/cert match (Linux / macOS)
    ├── nonserve/
    │   └── .env                   ← secrets; Apache can never serve this directory
    ├── refresh.sh                 ← set ownership and permissions server-side
    └── mysitename.com/
        └── index.php              ← starter index page

After the script finishes, copy your .pem key file (downloaded from Lightsail) into the new directory and rename it to match: mysitename.pem.

The create-site Scripts

The Windows and Linux versions produce identical results. Both confirm before creating, refuse to overwrite an existing directory, and print the resulting tree. The Windows version writes a temporary PowerShell script to handle the text substitution, then deletes it.

●●● create-site.bat
@echo off
setlocal

if "%~1"=="" (
    echo Usage: %~nx0 ^<install-path^> ^<site-name^>
    exit /b 1
)
if "%~2"=="" (
    echo ERROR: site-name parameter required.
    echo Usage: %~nx0 ^<install-path^> ^<site-name^>
    exit /b 1
)

set INSTALLDIR=%~1
set SITENAME=%~2
set TEMPLATE_DIR=%~dp0template
if "%TEMPLATE_DIR:~-1%"=="\" set TEMPLATE_DIR=%TEMPLATE_DIR:~0,-1%
set NEW_DIR=%INSTALLDIR%\%SITENAME%

if not exist "%TEMPLATE_DIR%\" (
    echo ERROR: Template directory not found: %TEMPLATE_DIR%
    exit /b 1
)
if not exist "%INSTALLDIR%\" (
    echo ERROR: Install path does not exist: %INSTALLDIR%
    exit /b 1
)

echo.
echo This will clone the template directory to create a new site:
echo.
echo   Source : %TEMPLATE_DIR%
echo   Target : %NEW_DIR%
echo.
echo All occurrences of "template" will be replaced with "%SITENAME%"
echo in directory names, file names, and file contents.
echo.
set /p CONFIRM=Proceed? [Y/N]:
if /i not "%CONFIRM%"=="Y" (
    echo Aborted.
    exit /b 0
)

if exist "%NEW_DIR%" (
    echo ERROR: "%NEW_DIR%" already exists.
    exit /b 1
)

echo.
echo Creating "%SITENAME%"...

set PSFILE=%TEMP%\create_site_%RANDOM%.ps1
(
    echo param^($s, $t, $n^)
    echo Get-ChildItem -LiteralPath $t -Recurse -File ^| ForEach-Object {
    echo     $rel = $_.FullName.Substring^($t.Length + 1^)
    echo     $newRel = $rel -replace 'template', $s
    echo     $dest = Join-Path $n $newRel
    echo     $destDir = Split-Path $dest -Parent
    echo     if ^(-not ^(Test-Path $destDir^)^) { New-Item -ItemType Directory -Path $destDir ^| Out-Null }
    echo     $content = [IO.File]::ReadAllText^($_.FullName^)
    echo     $newContent = $content -replace 'template', $s
    echo     [IO.File]::WriteAllText^($dest, $newContent^)
    echo }
) > "%PSFILE%"

powershell -NoProfile -ExecutionPolicy Bypass -File "%PSFILE%" -s "%SITENAME%" -t "%TEMPLATE_DIR%" -n "%NEW_DIR%"
set PSERR=%ERRORLEVEL%
del "%PSFILE%" 2>nul

if %PSERR% neq 0 (
    echo ERROR: Site creation failed.
    exit /b 1
)

echo.
echo Done. Tree of %NEW_DIR%:
echo.
TREE /F "%NEW_DIR%"
●●● create-site.sh
#!/usr/bin/env bash
set -e

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
TEMPLATE_DIR="$SCRIPT_DIR/template"

if [ -z "$1" ]; then
    echo "Usage: $(basename "$0") <install-path> <site-name>"
    exit 1
fi
if [ -z "$2" ]; then
    echo "ERROR: site-name parameter required."
    echo "Usage: $(basename "$0") <install-path> <site-name>"
    exit 1
fi

INSTALLDIR="$1"
SITENAME="$2"
NEW_DIR="$INSTALLDIR/$SITENAME"

if [ ! -d "$TEMPLATE_DIR" ]; then
    echo "ERROR: Template directory not found: $TEMPLATE_DIR"
    exit 1
fi
if [ ! -d "$INSTALLDIR" ]; then
    echo "ERROR: Install path does not exist: $INSTALLDIR"
    exit 1
fi

echo
echo "This will clone the template directory to create a new site:"
echo
echo "  Source : $TEMPLATE_DIR"
echo "  Target : $NEW_DIR"
echo
echo "All occurrences of \"template\" will be replaced with \"$SITENAME\""
echo "in directory names, file names, and file contents."
echo "CR/LF line endings will be converted to LF."
echo
read -rp "Proceed? [Y/N]: " CONFIRM
if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
    echo "Aborted."
    exit 0
fi

if [ -d "$NEW_DIR" ]; then
    echo "ERROR: \"$NEW_DIR\" already exists."
    exit 1
fi

echo
echo "Creating \"$SITENAME\"..."

find "$TEMPLATE_DIR" -type f | while IFS= read -r src; do
    rel="${src#${TEMPLATE_DIR}/}"
    newrel="${rel//template/$SITENAME}"
    dest="$NEW_DIR/$newrel"
    mkdir -p "$(dirname "$dest")"
    sed 's/\r$//' "$src" | sed "s|template|$SITENAME|g" > "$dest"
    chmod "$(stat -c '%a' "$src")" "$dest"
done

echo
echo "Done. Tree of $NEW_DIR:"
echo
find "$NEW_DIR" | sort

Provision the Server

SSH in using the mysitename-ssh.bat script that create-site generated, or directly from a terminal:

ssh -i mysitename.pem ubuntu@YOUR_STATIC_IP

Update the OS and install the LAMP stack:

# Update the OS
sudo apt update && sudo apt upgrade -y

# Apache + required modules
sudo apt install apache2 -y
sudo a2enmod ssl rewrite
sudo systemctl restart apache2

# PHP and the Apache PHP module
sudo apt install php php-mysql libapache2-mod-php -y

# MariaDB
sudo apt install mariadb-server -y
sudo mysql_secure_installation

The mysql_secure_installation script walks through removing anonymous users, disabling remote root login, and dropping the test database. Answer yes to every prompt.

Create a Database and User

sudo mariadb

CREATE DATABASE mysitename CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'mysitename'@'localhost' IDENTIFIED BY 'a-strong-password';
GRANT ALL PRIVILEGES ON mysitename.* TO 'mysitename'@'localhost';
FLUSH PRIVILEGES;
EXIT;
Grant only localhost access unless you specifically need remote connections. Keeping MariaDB off the public network is the most effective single database security measure you can take.

Install Your SSL Certificate

Place your certificate files in the website/csr/ subdirectory of your local working directory before uploading:

mysitename/website/csr/
├── mysitename.key                  ← private key
├── mysitename.crt                  ← signed certificate from your CA
└── mysitename-intermediate.pem     ← CA chain file (if provided)

Before uploading, verify that the key matches the certificate. A mismatched pair will cause Apache to refuse to start:

# Windows
website\csr\match.bat

# Linux / macOS
./website/csr/match.sh

# Both scripts print the MD5 hash of the key modulus and the cert modulus.
# The two hashes must be identical. If they differ, you have the wrong key.
Certificate filename mismatch — common mistake: The cert and key filenames in website/csr/ must exactly match the paths declared in mysitename-ssl.conf. If your CA issued files named mysite.crt / mysite.key or mysite.com.crt / mysite.com.key, open mysitename-ssl.conf and confirm the SSLCertificateFile and SSLCertificateKeyFile lines use those exact names before continuing.

Once verified, run uploadall.bat to push everything to the server. Then SSH in and install the certificates:

cd /var/www/mysitename/website/csr
./copythem.sh

copythem.sh copies *.crt and *.pem to /etc/ssl/certs/ and *.key to /etc/ssl/private/. The paths in mysitename-ssl.conf reference these locations.

Connect Your Site to Apache

Two-box diagram. Left box labelled Your Machine shows the mysitename working directory with its files. A blue arrow labelled uploadall.bat and SCP plus SSH over port 22 points right. A grey arrow labelled refresh.sh permissions and ownership points back left. Right box labelled Lightsail Server shows /var/www/mysitename with subdirectories apache2, csr, mysitename.com (marked docroot), nonserve, and refresh.sh.
uploadall.bat SCPs the entire website/ tree, then SSHes in to run refresh.sh which sets the correct ownership and permissions server-side.

SSH into your server. The Apache management scripts live in /var/www/mysitename/website/apache2/.

Step 1 — Enable HTTP First

cd /var/www/mysitename/website/apache2
sudo ./setup.sh

setup.sh copies mysitename.conf to /etc/apache2/sites-available/, runs a2ensite, and restarts Apache. Open a browser and navigate to http://mysitename.com — you should see the starter index page.

Step 2 — Switch to HTTPS

cd /var/www/mysitename/website/apache2
sudo ./update-ssl.sh

update-ssl.sh disables the HTTP-only vhost and enables the SSL vhost. The SSL vhost redirects all plain HTTP traffic to HTTPS and terminates TLS using the certificates you installed with copythem.sh. Test at https://mysitename.com.

Disable the Apache Default Site

sudo a2dissite 000-default.conf
sudo systemctl restart apache2

The default site catches any request that doesn’t match a configured virtual host — including direct requests to the IP address. Disabling it means Apache returns nothing for requests that don’t match a ServerName.

Deploying Changes

Two scripts handle all deployment, both run from your local working directory.

●●● uploadall.bat — full deploy (initial setup and after infrastructure changes)
scp -i mysitename.pem -r mysitename/website/. ubuntu@mysitename.com:/var/www/mysitename
ssh -i mysitename.pem ubuntu@mysitename.com "find /var/www/mysitename -type f -print0 | xargs -0 sed -i 's/\r$//'"
ssh -i mysitename.pem ubuntu@mysitename.com "sudo bash /var/www/mysitename/refresh.sh"
●●● uploadcontent-web.bat — content-only deploy (day-to-day edits)
robocopy ..\mysitename\website\mysitename.com C:\mysitename-upload-tmp /E /XF Dockerfile composer.json composer.lock docker-compose.yml /XD vendor
scp -i ..\mysitename\mysitename.pem -r C:/mysitename-upload-tmp/. ubuntu@mysitename.com:/var/www/mysitename/mysitename.com
ssh -i ..\mysitename\mysitename.pem ubuntu@mysitename.com "sudo bash /var/www/mysitename/refresh.sh"
rd /s /q C:\mysitename-upload-tmp

uploadcontent-web.bat is faster for routine content edits because it only copies the public docroot and excludes Docker and Composer files. Use uploadall.bat for the initial deployment and after any change to Apache configs, shell scripts, or the nonserve/ directory.

refresh.sh (called by both scripts at the end) sets server-side ownership and permissions: ubuntu:www-data ownership, 2755 on directories, 644 on files, and 755 on shell scripts.

Keep Secrets Out of Your Code

Database passwords, API keys, and other credentials belong in a .env file — never in committed source files. The template places .env in website/nonserve/, one directory above the Apache document root. Apache’s document root is website/mysitename.com/, so nonserve/ is unreachable even if Apache is misconfigured.

Install Composer and DotEnv (server-side, once)

# Install Composer
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer

# In your site's docroot
cd /var/www/mysitename/mysitename.com
composer require vlucas/phpdotenv

Populate .env

Edit website/nonserve/.env locally, then upload with uploadall.bat. Do not commit this file to version control:

MARIADB_HOST=localhost
MARIADB_USER=mysitename
MARIADB_PASS=your-strong-password
MARIADB_NAME=mysitename

Load .env in PHP

require_once __DIR__ . '/vendor/autoload.php';
(Dotenv\Dotenv::createImmutable(__DIR__ . '/../nonserve/'))->load();

// Access credentials via $_ENV
$host = $_ENV['MARIADB_HOST'];
$user = $_ENV['MARIADB_USER'];
$pass = $_ENV['MARIADB_PASS'];

Set Up a Git Repository

Version-control your working directory from the start. Initialize from the site root:

cd mysitename
git init
git add .
git commit -m "initial scaffold"

Add a .gitignore before that first commit to exclude credentials, server-installed dependencies, and the private key:

website/nonserve/.env
website/mysitename.com/vendor/
*.pem
Never commit .env or .pem files. A credential committed even briefly — even to a private repository — should be treated as compromised. Rotate it immediately.

Multiple Sites on One Server

A single Lightsail instance can host additional domains without significant extra work. For each additional site, repeat the same steps:

  1. Run create-site for the new site name to create a second working directory.
  2. Place the new site’s SSL certs in website/csr/.
  3. Run uploadall.bat — this installs to /var/www/newsitename/, completely independently of the first site.
  4. SSH in, run copythem.sh to install certs, then update-ssl.sh to enable the new vhost.

Apache routes incoming requests by ServerName in each -ssl.conf file. Each site is isolated in its own /var/www/newsitename/ tree. Compromised credentials for one site do not affect the others.

/var/www/
├── mysitename/          ← first site
│   └── mysitename.com/  ← docroot for mysitename.com
└── othersitename/       ← second site
    └── othersitename.com/ ← docroot for othersitename.com

Security Basics

A fresh server with a stock Ubuntu install passes most automated scans. A few hardening steps are worth doing before you put real content on it:

  • Confirm key-only SSH. Lightsail disables password SSH by default. Verify it’s still set in /etc/ssh/sshd_config: PasswordAuthentication no.
  • Keep packages updated. Run sudo apt update && sudo apt upgrade regularly, or enable automatic security updates: sudo apt install unattended-upgrades.
  • Run Lynis. sudo apt install lynis && sudo lynis audit system produces a scored report with specific hardening suggestions for your exact configuration.
  • Install Fail2ban. sudo apt install fail2ban automatically blocks IPs that exceed failed SSH login thresholds.
  • Review open ports. sudo ufw status verbose shows what is and isn’t open at the OS level. Close anything you didn’t intentionally open in the Lightsail firewall.

Additional security review is content-specific. A PHP site with a database needs different hardening than a static file server. That is covered in the security audit article.

What Comes Next

  • Local testing with Docker Compose. Before uploading, run your site in a local container that mirrors the Ubuntu + Apache + PHP production environment. A docker-compose.yml and Dockerfile for this setup are covered in the Docker Compose article.
  • Development with Claude Code. Claude runs from the working directory, reads your CLAUDE.md, and deploys via the same upload scripts. The workflow article describes how to structure a project for effective AI-assisted development.
  • Database-backed pages. The server is ready for any content once it’s running. Subsequent articles cover PHP sessions and auth, secure file handling with S3, and the full mrworkbook.com site architecture.
  • Running a git server. A future article covers setting up Gitea on Lightsail for private repository hosting on the same or a separate instance.

Checklist

Prerequisites:
  [ ] Domain name registered
  [ ] SSL certificate obtained (Let’s Encrypt or commercial CA)
  [ ] AWS account created

Lightsail setup:
  [ ] Instance launched — Ubuntu 24.04 LTS, OS Only blueprint
  [ ] Default .pem key downloaded and saved immediately (cannot be retrieved later)
  [ ] Static IP created and attached to the instance
  [ ] Firewall: port 443 open, port 3306 only if remote DB access is needed
  [ ] DNS A record pointing domain to static IP

Local working directory:
  [ ] websitetools.zip downloaded and extracted
  [ ] create-site script run — working directory created
  [ ] .pem key file copied into the new directory and renamed to sitename.pem
  [ ] SSL cert files placed in website/csr/ (.key, .crt, intermediate .pem)
  [ ] match.bat / match.sh run — key and cert hashes are identical
  [ ] .gitignore created excluding .env, vendor/, *.pem
  [ ] git repository initialized

Server provisioning:
  [ ] apt update && apt upgrade run
  [ ] Apache installed; mod_ssl and mod_rewrite enabled
  [ ] PHP 8.3 installed with php-mysql and libapache2-mod-php
  [ ] MariaDB installed; mysql_secure_installation completed
  [ ] Site database and localhost-only user created

SSL and Apache:
  [ ] uploadall.bat run — full site deployed to /var/www/sitename/
  [ ] copythem.sh run server-side — certs installed into /etc/ssl/
  [ ] setup.sh run — HTTP vhost enabled; site loads over http://
  [ ] update-ssl.sh run — HTTPS vhost active; site loads over https://
  [ ] 000-default.conf disabled

Secrets and dependencies:
  [ ] Composer installed server-side; vlucas/phpdotenv required
  [ ] website/nonserve/.env populated with DB credentials
  [ ] .env confirmed absent from git history and .gitignore covers it

Security basics:
  [ ] SSH password auth confirmed disabled (PasswordAuthentication no)
  [ ] Fail2ban installed
  [ ] Lynis audit run; critical findings addressed
  [ ] UFW open ports reviewed against Lightsail firewall rules
top