01Services02Process03Projects04About05FAQ06Blog07Hire Me

25+ products shipped · $3.8M+ raised by clients

Back to Blog
InfrastructurePart 5 of 9September 28, 202614 min read

Laravel + Postgres on 1 GB of memory

Subhankar Denria

Subhankar Denria

Software Architect · Product Engineer

~11 min

What this part does

Turn the bare Ubuntu machine into a working API — PHP, Postgres, a web server, and the two every-minute jobs — and put the code live in a way that makes the next update, and the one after, boring.

Time
About 20 minutes of installing
Cost
$0 — all open source
If you skip a step
Crashes when memory runs out, or updates that keep serving old code

I did this with two scripts: provision.sh (prepare the server, once) and deploy.sh (put a new version live, every time). This post walks through what they do and why, with the important parts in full. Each step is written so it's safe to run twice — it checks before it acts — which turns "I'm not sure that worked" into "run it again".

Test your scripts before the real server. I ran both scripts, twice each, on fresh Ubuntu 26.04 and 24.04 in Docker containers first, and fixed three bugs there instead of on a live machine. A container is free and disposable; the real server isn't.

Part A — Preparing the server (provision.sh)

The whole thing takes about 20 minutes, mostly downloading. Here are its ten steps:

1

What it does
Adds 2 GB of swap
Why
1 GB of memory is tight; this is the safety net

2

What it does
Checks for IPv4, and if there's none, tells the installer to use IPv6 only
Why
Otherwise every download waits minutes for IPv4 to time out

3

What it does
Adds Cloudflare's package source (and, on 24.04 only, sources for newer PHP and Postgres)
Why
For cloudflared, the tunnel program

4

What it does
Installs PHP 8.5, Postgres 18, nginx, cloudflared
Why
The software itself

5

What it does
Tunes Postgres for 1 GB and makes it listen on the server only
Why
Default settings assume a much bigger machine

6

What it does
Creates the app user, folders, database and settings file, with a fresh random password and app key
Why
Secrets are born on the server and never pass through a laptop or a screenshot

7

What it does
Sets up PHP-FPM: a small pool of up to 6 PHP workers
Why
Memory, and database connections

8

What it does
Sets up nginx on 127.0.0.1:8080 only
Why
Only the tunnel, on the same machine, can reach it

9

What it does
Installs the two every-minute timers
Why
The heartbeat

10

What it does
Turns on automatic security updates, with automatic reboot off
Why
Patches arrive on their own; the server never restarts without you knowing

The interesting parts, one by one.

Swap: a safety net for 1 GB

Google's images come with no swap. Laravel and Postgres together, in 1 GB, without it, means that one day the kernel's out-of-memory killer picks one of them to stop — and it doesn't choose politely.

bash
fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
echo 'vm.swappiness=10' > /etc/sysctl.d/99-swap.conf   # use it only when really needed
sysctl -p /etc/sysctl.d/99-swap.conf                     # apply now, not just after a reboot

Swap on a standard disk is slow, so swappiness=10 tells Linux to prefer real memory and treat swap as a last resort. In practice the whole stack uses about 400 MB and swap stays nearly empty — but it's there on the bad day.

1 GB, spent carefully

Memory · 1 GB

~400 MB · the whole stack~600 MB free

nginx, PHP, Postgres and both every-minute jobs, together.

Swap · 2 GB on disk

Nearly empty — a safety net for the bad day, used only when really needed

Database connections · max_connections = 20

6 PHP web workers 2 ticks headroom

Workers start on demand: on a quiet night there are none, holding no memory at all.

Telling the installer to use IPv6

On an IPv6-only server, a connection attempt to an IPv4 address doesn't fail quickly — it hangs until it times out. Ubuntu's package mirrors have both kinds of address, so every download wasted minutes. One line fixes it:

bash
echo 'Acquire::ForceIPv6 "true";' > /etc/apt/apt.conf.d/99force-ipv6

The script only writes this after checking that IPv4 really is missing, so the same script works on a normal server too.

Postgres, sized for a small machine

ini
# /etc/postgresql/18/main/conf.d/lampsill.conf
listen_addresses = 'localhost'   # nothing outside the server can connect
shared_buffers = 128MB
effective_cache_size = 512MB     # a hint to the planner; uses no memory
work_mem = 4MB                   # per sort, per query — keep it small
maintenance_work_mem = 64MB
max_connections = 20

max_connections and the PHP worker count are decided together, because each PHP worker can hold one database connection: 6 web workers + 2 ticks + headroom = 20.

PHP-FPM: workers on demand

ini
[lampsill]
user = lampsill
group = lampsill
listen = /run/php/lampsill.sock
listen.owner = www-data    # nginx runs as www-data; without these two
listen.group = www-data    # lines it can't open the socket, and every page is a 502
pm = ondemand              # start workers only when requests arrive
pm.max_children = 6
pm.process_idle_timeout = 20s
php_admin_value[memory_limit] = 128M

ondemand means that on a quiet night, no PHP workers sit around holding memory. The app runs as its own user, lampsill, not as root or as the web server's user.

nginx: listening to nobody but the tunnel

nginx
server {
    listen 127.0.0.1:8080 default_server;     # loopback only
    root /srv/lampsill/current/public;

    # Every request arrives from the tunnel on 127.0.0.1. Without this,
    # every visitor would appear to have the same IP address, and one
    # person's failed logins would lock everyone out.
    set_real_ip_from 127.0.0.1;
    set_real_ip_from ::1;
    real_ip_header CF-Connecting-IP;

    location / { try_files $uri /index.php?$query_string; }

    location ~ \.php$ {
        include fastcgi_params;
        # $realpath_root, not $document_root: "current" is a symlink that
        # each deploy swaps, and PHP must see the real folder, or its cache
        # keeps serving the previous version.
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $realpath_root;
        fastcgi_param HTTPS on;               # HTTPS ends at Cloudflare
        fastcgi_pass unix:/run/php/lampsill.sock;
    }
}

Three lines in there each prevent a confusing bug:

  • 127.0.0.1:8080 — nginx is unreachable from the internet even if the firewall were wrong. Two independent locks.
  • real_ip_header CF-Connecting-IP — Cloudflare passes along the visitor's real address. Laravel's "6 login attempts per minute" limit is per address; without this line, it's one limit shared by the whole world.
  • $realpath_root — explained below, under deploys.

Part B — The every-minute jobs: systemd timers, not cron

The classic way to run something every minute on Linux is cron. I used systemd timers instead, for two reasons that matter for a job like this:

  1. 1No overlapping runs. If one run takes longer than a minute, cron happily starts a second copy alongside it. A systemd oneshot service won't start again while it's still running.
  2. 2History. journalctl -u lampsill-tick shows every run, its output and its exit code. At 4 a.m., when something is wrong, that matters.
The minute a run takes longer than a minute

cron

systemd timer + oneshot service

0:001:002:003:004:005:00
The amber run at 2:00 takes a minute and a half. At 3:00, cron starts another alongside it. The systemd service is still running, so no second copy starts.

Each job is two small files. The service says what to run:

ini
# /etc/systemd/system/lampsill-tick.service
[Unit]
Description=Lampsill escalation tick
After=network-online.target postgresql.service
Wants=network-online.target

[Service]
Type=oneshot
User=lampsill
Group=lampsill
WorkingDirectory=/srv/lampsill/current
EnvironmentFile=-/etc/lampsill/healthchecks.env
ExecStart=/usr/bin/php artisan lampsill:tick
ExecStartPost=/bin/sh -c 'if [ -n "$HC_TICK_URL" ]; then curl -fsS -m 10 --retry 3 -o /dev/null "$HC_TICK_URL" || true; fi'

The timer says when:

ini
# /etc/systemd/system/lampsill-tick.timer
[Timer]
OnBootSec=1min
OnUnitActiveSec=1min
AccuracySec=1s

[Install]
WantedBy=timers.target
  • OnUnitActiveSec=1min — one minute after the last run started.
  • AccuracySec=1s — systemd's default accuracy is a whole minute: it would happily delay runs to group them together and save power. On an alert ladder measured in minutes, that's not a saving worth making.
  • ExecStartPost — the "I'm alive" ping to the outside alarm. It only runs if the job itself succeeded. That's the whole trick of post 7.

The second job, lampsill-silence-tick, is an identical pair of files running a different command. I kept them separate on purpose: the two features are kept apart everywhere else in the code, and one failing shouldn't take down the other.

Part C — Deploying without GitHub

The problem

A Laravel app needs its libraries — the vendor/ folder — installed with Composer, which downloads them from GitHub. And GitHub has no IPv6 address. Our server can't reach it.

The answer: build on the laptop, upload the result

A small script on my Mac, release.sh, builds a finished package:

bash
# 1. Copy the app, leaving out everything secret or local
rsync -a --exclude='.git/' --exclude='.env' --exclude='.env.*' \
  --exclude='vendor/' --exclude='node_modules/' --exclude='tests/' \
  --exclude='storage/logs/*' --exclude='database/*.sqlite' \
  ./ "$WORK/"

# 2. Install production libraries only
( cd "$WORK" && composer install --no-dev --optimize-autoloader --no-interaction )

# 3. Pack it — without macOS's hidden metadata files
COPYFILE_DISABLE=1 tar --no-xattrs --no-mac-metadata -czf ~/Desktop/lampsill-api-$STAMP.tar.gz -C "$WORK" .

The result is a ~5 MB file on the Desktop. Two bonuses: nothing secret ever leaves the laptop (no .env, no local database, no logs), and the 1 GB server never spends its memory on Composer's dependency solving.

The macOS flags: without COPYFILE_DISABLE=1 and --no-xattrs, macOS's tar slips hidden ._filename files and extended attributes into the archive, and Linux's tar prints a screen of warnings about them on the server.

Uploading: the browser SSH window has an UPLOAD FILE button, top right. The file lands in your home folder. That's the simplest way to get a file onto a server with no public IPv4.

Releases and a symlink

Each deploy unpacks into its own folder, and a symlink — a shortcut — called current points at the live one:

text
/srv/lampsill/
├── current -> releases/20260928-155045     ← what nginx and the timers use
├── releases/
│   ├── 20260928-143956
│   └── 20260928-155045
└── shared/
    ├── .env          ← settings: shared by every release, never in a package
    └── storage/      ← logs, cache: survive every deploy

deploy.sh, in order:

bash
# 1. Unpack into a new release folder
tar -xzf "$TARBALL" -C "$REL" --no-same-owner
# 2. Link in the shared settings and storage
ln -s /srv/lampsill/shared/storage "$REL/storage"
ln -sfn /srv/lampsill/shared/.env "$REL/.env"
# 3. Migrate the database and cache the config — BEFORE going live
runuser -u lampsill -- php artisan migrate --force
runuser -u lampsill -- php artisan config:cache
runuser -u lampsill -- php artisan route:cache
# 4. Go live in one atomic step
ln -sfn "$REL" /srv/lampsill/current.new
mv -Tf /srv/lampsill/current.new /srv/lampsill/current
systemctl reload-or-restart php8.5-fpm
# 5. Keep the three newest releases, delete the rest

Why this shape:

  • Nothing changes until everything is ready. If a migration fails, the script stops and the old release keeps running.
  • mv -T swaps the symlink atomically. There's no moment where current is missing or half-written. The API and the every-minute jobs move from old code to new at the same instant.
  • Rolling back is one command: point current at the previous folder.
  • $realpath_root in nginx makes PHP see the real release folder, not the symlink. PHP caches compiled code by path; through the symlink it would keep serving the old version after a deploy.
A deploy, step by step
  1. 1Unpack into a new release folder
  2. 2Link in the shared .env and storage
  3. 3Migrate the database, cache config and routes
  4. 4Swap the current symlink — in one step
  5. 5Keep the three newest releases

/srv/lampsill/

current →releases/…143956

releases/

20260928-143956live
20260928-155045building…
shared/ · .env and storage, kept across every deploy
If step 3 fails, the script stops and current never moves: the old release keeps serving. Rolling back is pointing current at the previous folder.

A normal update is now: bash deploy/release.sh on the Mac → UPLOAD FILE → sudo bash deploy/deploy.sh ~/lampsill-api-<new>.tar.gz. About two minutes.

What went wrong (or nearly did)

  • Downloads crawled until the installer was told to use IPv6 only.
  • Re-running provision.sh failed in testing because it restarted nginx where it should have reloaded it. Found in Docker, not on the server — which is the point of testing there.
  • A typo in the docs — cd "~/folder" — doesn't work, because the ~ isn't expanded inside quotes. It has to be cd ~/"folder".

What you should see

On the server, after the first deploy:

bash
curl -s http://127.0.0.1:8080/up -o /dev/null -w '%{http_code}\n'   # 200
systemctl list-timers 'lampsill*'                                    # both timers, with a NEXT time
free -m                                                              # ~400 MB used, swap ~2047

The API is running — but only the server itself can see it. Next, we give it a public address without opening a single port.

Let's connect

Choose your preferred way

Available for new projects