Laravel + Postgres on 1 GB of memory

Subhankar Denria
Software Architect · Product Engineer
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:
| Step | What it does | Why |
|---|---|---|
| 1 | Adds 2 GB of swap | 1 GB of memory is tight; this is the safety net |
| 2 | Checks for IPv4, and if there's none, tells the installer to use IPv6 only | Otherwise every download waits minutes for IPv4 to time out |
| 3 | Adds Cloudflare's package source (and, on 24.04 only, sources for newer PHP and Postgres) | For cloudflared, the tunnel program |
| 4 | Installs PHP 8.5, Postgres 18, nginx, cloudflared | The software itself |
| 5 | Tunes Postgres for 1 GB and makes it listen on the server only | Default settings assume a much bigger machine |
| 6 | Creates the app user, folders, database and settings file, with a fresh random password and app key | Secrets are born on the server and never pass through a laptop or a screenshot |
| 7 | Sets up PHP-FPM: a small pool of up to 6 PHP workers | Memory, and database connections |
| 8 | Sets up nginx on 127.0.0.1:8080 only | Only the tunnel, on the same machine, can reach it |
| 9 | Installs the two every-minute timers | The heartbeat |
| 10 | Turns on automatic security updates, with automatic reboot off | Patches arrive on their own; the server never restarts without you knowing |
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:8080only - 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.
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 rebootSwap 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.
Memory · 1 GB
nginx, PHP, Postgres and both every-minute jobs, together.
Swap · 2 GB on disk
Database connections · max_connections = 20
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:
echo 'Acquire::ForceIPv6 "true";' > /etc/apt/apt.conf.d/99force-ipv6The 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
# /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 = 20max_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
[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] = 128Mondemand 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
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:
- 1No overlapping runs. If one run takes longer than a minute, cron happily starts a second copy alongside it. A systemd
oneshotservice won't start again while it's still running. - 2History.
journalctl -u lampsill-tickshows every run, its output and its exit code. At 4 a.m., when something is wrong, that matters.
cron
systemd timer + oneshot service
Each job is two small files. The service says what to run:
# /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:
# /etc/systemd/system/lampsill-tick.timer
[Timer]
OnBootSec=1min
OnUnitActiveSec=1min
AccuracySec=1s
[Install]
WantedBy=timers.targetOnUnitActiveSec=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:
# 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:
/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 deploydeploy.sh, in order:
# 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 restWhy this shape:
- Nothing changes until everything is ready. If a migration fails, the script stops and the old release keeps running.
mv -Tswaps the symlink atomically. There's no moment wherecurrentis 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
currentat the previous folder. $realpath_rootin 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.
- 1Unpack into a new release folder
- 2Link in the shared .env and storage
- 3Migrate the database, cache config and routes
- 4Swap the current symlink — in one step
- 5Keep the three newest releases
/srv/lampsill/
releases/
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.shfailed 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 becd ~/"folder".
What you should see
On the server, after the first deploy:
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 ~2047The API is running — but only the server itself can see it. Next, we give it a public address without opening a single port.
Next up · Part 6 of 9 · 12 min read
A public HTTPS address with no open ports
Give the API a public HTTPS address — api.example.com — that the app can call, without opening any door on the server. Then put a free rate limit in front of it.
Keep going Part 4: Creating the free server without paying for itWritten by
Subhankar Denria
Software Architect · 25+ products shipped