# Ship Your Side Project to the Internet

> The full journey from 'it works on my laptop' to 'a stranger can use it at a real URL' - pick a VPS, SSH in, run it with Docker, point a domain, put it behind Cloudflare, and auto-deploy on merge - with every first-time gotcha flagged.


---

# Ship Your Side Project to the Internet

You built something. It runs on `localhost:3000` and it works. And now there's a gap - the one nobody
warns you about - between "it works on my machine" and "a stranger can open a URL and use it." That gap
is small in concept and full of tiny, infuriating traps: billing that keeps running after you power off,
an SSH key that's "permission denied" for five different reasons, a `$` in your password that quietly
breaks everything, a login that works locally and silently fails in production.

This guide walks the **whole journey, once, end to end** - the path a real first deploy actually takes -
and flags every trap as a ⚠️ so it bites the page instead of you. We won't re-teach SSH or Docker or DNS
from scratch; each has its own guide, and we'll link them. This is the *map* that strings them together
into "my thing is live."

```mermaid
flowchart LR
  Local[your laptop] --> VPS[a VPS] --> Docker[Docker compose up] --> DNS[domain + DNS] --> CF[Cloudflare + HTTPS] --> Auto[auto-deploy on merge]
```

## How to read this
- **First deploy ever?** Read in order - it's the literal sequence you'll follow, top to bottom.
- **Stuck on one step?** Each phase is self-contained; jump to the one that's fighting you (the ⚠️
  callouts are the fixes).

## The phases
1. **[Pick a Cheap VPS](01-pick-a-vps.md)** - what specs you *actually* need, why the build needs more
   RAM than the app, and the billing trap.
2. **[SSH In With a Key](02-ssh-in-with-a-key.md)** - keys not passwords, and the "Permission denied"
   decision tree.
3. **[Docker & Your Private Repo](03-docker-and-your-repo.md)** - install Docker, get a *private* repo
   onto the box with a deploy key, `compose up`.
4. **[Domains & DNS](04-domains-and-dns.md)** - buy one, point it, apex vs www, and the `.dev` HTTPS trap.
5. **[Behind Cloudflare](05-behind-cloudflare.md)** - free HTTPS, no open ports, and the cookie/CSRF
   must-dos that break logins.
6. **[Auto-Deploy on Merge](06-auto-deploy-on-merge.md)** - merge to `main`, and it's live.

> Every step here builds on a guide that goes deeper:
> [What a Server Is](/guides/what-a-server-is) · [SSH & Keys](/guides/ssh-and-keys) ·
> [Docker Without the Magic](/guides/docker-without-the-magic) ·
> [Docker Compose for Real Projects](/guides/docker-compose-for-real-projects) ·
> [Deploying to a VPS](/guides/deploying-to-a-vps) · [HTTPS & TLS](/guides/https-and-tls) ·
> [Environment Variables & Config](/guides/env-vars-and-config) ·
> [Your First Pipeline (GitHub Actions)](/guides/your-first-pipeline-github-actions). This guide is the
> end-to-end thread; reach for those when you want the full picture of one piece.


---

# Pick a Cheap VPS

The first decision is also the first place people overspend or under-provision: which box to rent. A
**VPS** (virtual private server) is a slice of a real server in a data center that's yours to do anything
with - your own Linux machine, always on, with a public IP. (If "what's a server, really?" is fuzzy,
[What a Server Is](/guides/what-a-server-is) covers it from scratch.)

For a side project you need far less than you'd think - with one catch that surprises everybody.

## What specs you actually need

For a typical small web app + database in Docker:

- **CPU:** 1 vCPU is fine to start; 2 if you can spare it. Side-project traffic is tiny.
- **Disk:** 20–25 GB is plenty - until Docker images and logs pile up (we'll watch that later).
- **RAM:** this is the one that matters, and the one with the trap below.

A typical cheapest tier (around **1 vCPU / 1 GB RAM / 25 GB disk**) will *run* most small apps
comfortably. The problem isn't running it. It's *building* it.

## ⚠️ Build-RAM vs runtime-RAM - the box must survive the build

Here's the trap. Your app might happily *run* in 200 MB. But **building** it - compiling code, installing
`node_modules`, bundling assets, `docker build` - can momentarily need *much* more, often more than a
1 GB box has. The build is the spike, not the steady state.

⚠️ **Cheap-VPS build OOM.** On a 1 GB box, `docker compose up --build` can die mid-build with a cryptic
`Killed` or exit code `137`. That's the **OOM killer** ([What "Out of Memory" Really Means](/guides/processes-memory-and-cpu))
reaping your build because it ran the box out of RAM. The build didn't fail because your code is wrong -
the machine ran out of room to build it. Three ways out:

1. **Add swap** - give the box emergency overflow memory on disk. Slow, but it lets a memory-hungry build
   *finish* instead of getting killed:
   ```console
   $ sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile
   $ sudo mkswap /swapfile && sudo swapon /swapfile
   $ echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
   ```
   *What just happened:* you created a 2 GB swap file, switched it on, and added it to `/etc/fstab` so it
   survives a reboot. The build now has headroom to spill into.
2. **Build somewhere else, ship the image.** Build the Docker image on your laptop or in CI, push it to a
   registry, and have the box just *pull and run* it - no build on the box at all. This is the grown-up
   answer, and [Phase 6](06-auto-deploy-on-merge.md) leans toward it.
3. **Temporarily resize.** Bump the box to more RAM for the build, then size back down. Fiddly; swap is
   usually easier.

💡 **Key point.** Choose the box for the *build's* peak, not the app's idle. The cheapest tier plus a
swap file covers most first deploys.

## ⚠️ You're billed while it's powered off - *delete* to stop paying

This one costs people real money. With a VPS, **stopping or powering off the server does not stop the
bill.** The provider is still reserving your CPU, RAM, and - especially - your disk, whether the machine
is "on" or not. "Powered off" is not "free."

To actually stop being charged, you must **destroy / delete** the server (the provider may call it
"Destroy," "Terminate," or "Delete"). If you want to keep the work first, take a **snapshot** (a saved
image you can restore later - note snapshots usually cost a small amount to store too).

⚠️ The classic version of this: you spin up a box to experiment over a weekend, "turn it off" Sunday
night thinking you're done, and find it on next month's invoice the whole time. Powering off saves you
nothing. Delete it.

## Recap

1. **1 vCPU / 1 GB / ~25 GB** runs most small apps - pick for the *build's* memory spike, not the app's
   idle.
2. ⚠️ **Builds OOM on tiny boxes** (`Killed` / exit 137) - add **swap**, or build elsewhere and pull the
   image, or temporarily resize.
3. ⚠️ **Powering off does NOT stop billing** - *delete* the server (snapshot first if you want to keep
   it) to actually stop paying.

You've got a box and an IP. Next, get into it - securely.


---

# SSH In With a Key

Your box has a public IP and you need a shell on it. The tool is **SSH**, and the right way in is a
**key**, not a password. This phase is the deploy-focused version; for how key pairs actually work - the
padlock-and-key mental model - read [SSH & Keys](/guides/ssh-and-keys) alongside it. Here we cover what
bites you the *first* time you point SSH at a brand-new server.

## Keys, in one paragraph

You generate a **key pair**: a *private* key that never leaves your laptop, and a *public* key that's
safe to hand out. You put the public key in the server's `~/.ssh/authorized_keys`; when you connect, SSH
proves you hold the matching private key without ever sending it. No password travels, and brute-forcing
a key is hopeless. Most VPS providers let you **paste your public key at creation** - do that, and the
box comes up already trusting you.

```console
$ ssh-keygen -t ed25519 -C "you@example.com"     # generates ~/.ssh/id_ed25519 (private) + .pub (public)
$ cat ~/.ssh/id_ed25519.pub                       # this is the line you paste into the provider / authorized_keys
$ ssh root@203.0.113.10                            # connect (user is often root or ubuntu on a fresh box)
```
*What just happened:* `ssh-keygen` made the pair; you copied the `.pub` (public) line to the server; `ssh`
connected as the box's initial user. The private `id_ed25519` stayed on your laptop the whole time.

📝 **On Windows.** Modern Windows 10/11 ship OpenSSH - `ssh` and `ssh-keygen` work in PowerShell out of
the box. If they don't, enable **Settings → Apps → Optional Features → OpenSSH Client** (one click), or
use the SSH that comes with Git Bash. You do *not* need PuTTY anymore.

## ⚠️ Use a key, then turn passwords off

A fresh box with password login is a magnet: bots hammer `root` with guessed passwords within *minutes*
of it getting a public IP. Once your key works, disable password login entirely on the server in
`/etc/ssh/sshd_config`:

```console
$ sudo nano /etc/ssh/sshd_config     # set:  PasswordAuthentication no
$ sudo systemctl restart ssh         # (on some distros the service is named sshd)
```
*What just happened:* you told the SSH server to stop accepting passwords at all - only keys. The bots
can now knock forever and never get in. ⚠️ **Confirm your key works in a second terminal *before* you log
out**, or a typo here can lock you out of your own box.

## ⚠️ The "Permission denied (publickey)" decision tree

You will see this error. It's almost never deep - it's one of a few mundane things. Walk it in order:

```mermaid
flowchart TD
  Denied[Permission denied publickey] --> User{right username?}
  User -->|no| FixUser[try root / ubuntu / the provider's user]
  User -->|yes| Installed{public key in authorized_keys?}
  Installed -->|no| FixKey[add your .pub to ~/.ssh/authorized_keys]
  Installed -->|yes| Offered{offering the right key?}
  Offered -->|no| FixOffer["ssh -i ~/.ssh/id_ed25519 ... (or set it in ~/.ssh/config)"]
  Offered -->|yes| Perms[tighten perms: chmod 700 ~/.ssh, 600 the key]
```

1. **Wrong username.** `ssh root@…` when the box's user is `ubuntu` (or vice versa). The username is part
   of who you're proving to be - right key, wrong user, still denied. Try the provider's default user.
2. **Public key not on the server.** Your `.pub` isn't in that user's `~/.ssh/authorized_keys`, so there's
   nothing to match. Paste it in (or re-create the box with the key attached).
3. **Wrong key offered.** You have several keys and SSH isn't offering the right one - point at it with
   `ssh -i ~/.ssh/id_ed25519 …` or name it in `~/.ssh/config`.
4. **Permissions too open.** SSH refuses a private key other users could read: `chmod 700 ~/.ssh` and
   `chmod 600 ~/.ssh/id_ed25519`.

(The full version of this checklist, with the verbose `ssh -v` trick, lives in
[SSH & Keys → Living With SSH](/guides/ssh-and-keys).)

## ⚠️ The provider's web console mangles pasted symbols

When SSH locks you out completely, every provider offers a **web/VNC console** - a browser window into
the box's screen. It's a genuine lifesaver, with one nasty quirk: that console emulates a keyboard, and
**pasting text with special characters into it often garbles them.** Paste a long key, a password with
`@ / $ | { }`, or a config line, and characters silently drop or change - so your "correct" key or
password mysteriously doesn't work. If you must use the console, type sensitive strings *by hand*, or
keep them simple, or (better) fix SSH so you never need the console. Many a "but I pasted it exactly!"
hour has died here.

## Recap

1. **Generate a key pair**, put the **public** half in the server's `authorized_keys` (or paste it at
   creation); the private half never leaves your laptop. Windows has `ssh`/`ssh-keygen` built in.
2. ⚠️ Once keys work, set **`PasswordAuthentication no`** - but confirm the key in a second terminal
   first so you don't lock yourself out.
3. ⚠️ **`Permission denied (publickey)`** is the decision tree above: username → key installed → key
   offered → file permissions.
4. ⚠️ The **web/VNC console garbles pasted symbols** - type sensitive strings by hand there.

You're on the box. Time to run your actual app.


---

# Docker & Your Private Repo

The box is yours and you're logged in. Now get your app *running* on it. We'll assume your project already
has a `docker-compose.yml` (if Docker or Compose themselves are fuzzy,
[Docker Without the Magic](/guides/docker-without-the-magic) and
[Docker Compose for Real Projects](/guides/docker-compose-for-real-projects) are the foundations this
phase stands on). Three moves: install Docker, get your code onto the box, bring it up.

## Install Docker

The official convenience script is the fastest path on a fresh Linux box:

```console
$ curl -fsSL https://get.docker.com | sh
$ sudo docker compose version
Docker Compose version v2.29.7
```
*What just happened:* the script installed Docker Engine and the Compose plugin. If `docker compose
version` answers, you're set. (Add your user to the `docker` group to drop the `sudo`:
`sudo usermod -aG docker $USER`, then log out and back in.)

## Get your PRIVATE repo onto the box: a deploy key

If your repo is **private**, `git clone` will be refused - the box has no permission to read it. The clean
fix is a **deploy key**: an SSH key pair whose public half you register on *that one repository*, granting
read-only access. Generate it **on the server** (so the private key never leaves the box), add the public
half to the repo, then clone over SSH.

```console
$ ssh-keygen -t ed25519 -C "deploy@myserver" -f ~/.ssh/deploy_key   # make a key ON the box
$ cat ~/.ssh/deploy_key.pub                                          # paste this into GitHub
```
On GitHub: **repo → Settings → Deploy keys → Add deploy key**, paste the `.pub`, leave "Allow write
access" **unchecked** (read-only is all a deploy needs). Then tell the box to use that key and clone:

```console
$ git clone git@github.com:you/your-project.git
Cloning into 'your-project'...
remote: Enumerating objects: 312, done.
Receiving objects: 100% (312/312), done.
```
*What just happened:* the deploy key proved the box is allowed to read *this* repo (and no other), so the
clone succeeded over SSH. 📝 **Deploy key** = an SSH key scoped to a single repository - safer than
putting your personal account's key on a server, because if the box is compromised, the blast radius is
read access to one repo.

## Bring it up

```console
$ cd your-project
$ cp .env.example .env && nano .env     # fill in your real secrets
$ docker compose up -d
[+] Running 3/3
 ✔ Container your-project-db-1   Started
 ✔ Container your-project-api-1  Started
 ✔ Container your-project-web-1  Started
```
*What just happened:* Compose built/pulled the images and started every service in the background (`-d`).
Your app is now *running* on the box - reachable at the server's IP on whatever port you exposed. (Not at
a domain or over HTTPS yet - that's the next two phases.)

⚠️ **Build OOM, again.** If `docker compose up` *builds* on a tiny box, it can get `Killed` (exit 137) for
the out-of-memory reason from [Phase 1](01-pick-a-vps.md). Same fixes: add swap, or build the image
elsewhere and pull it.

## ⚠️ The two `.env` traps that eat your afternoon

These are the deploy bugs that make people question reality, because nothing is "wrong" - the tool is
behaving exactly as designed, just not as you assumed.

⚠️ **A `$` in a value gets mangled.** Docker Compose performs *variable substitution* - it treats `$NAME`
and `${NAME}` in values as "insert another variable here." So a password like `pa$$w0rd` in your `.env`
becomes `paw0rd` (it tried to expand `$$` and `$w0rd`), and your database auth fails with a password you'd
*swear* is correct. The fix: **escape each `$` as `$$`** in values Compose reads, or generate secrets
without `$`:
```text
# .env  - a literal $ must be doubled so Compose doesn't try to expand it
DB_PASSWORD=pa$$$$w0rd      # the value the container receives is  pa$$w0rd
```
*What just happened:* each real `$` is written as `$$`, so Compose's substitution leaves a single `$`
behind in the value the container actually gets. This one is invisible until a login or DB connection
fails for no reason you can see.

⚠️ **`restart` does NOT re-read `.env`.** You change a value in `.env`, run `docker compose restart`, and
your change *doesn't take effect.* That's because `restart` just stops and starts the **existing**
containers - it does not re-read `.env` or the compose file. To actually apply new env or image changes,
**recreate** the containers:
```console
$ docker compose up -d --force-recreate
```
*What just happened:* `up -d` reconciles to the current `.env`/compose definition, and `--force-recreate`
forces new containers even if Compose thinks nothing changed - so your edited values are actually picked
up. Reach for this, not `restart`, whenever you touch `.env`. (Burn it in now; it comes back in
[Phase 6](06-auto-deploy-on-merge.md).)

> ⏭️ Why config lives in `.env` at all, and how `${...}` interpolation works, is
> [Environment Variables & Config](/guides/env-vars-and-config).

## Recap

1. **Install Docker** with the official script; `docker compose version` to confirm.
2. **Private repo → deploy key:** generate the key *on the box*, register its public half as a read-only
   deploy key on the repo, clone over SSH.
3. **`docker compose up -d`** brings the stack up in the background. ⚠️ Building on a tiny box can OOM -
   swap, or build elsewhere.
4. ⚠️ **A `$` in `.env` is mangled** by Compose substitution - double it (`$$`).
5. ⚠️ **`restart` ignores `.env` changes** - use **`docker compose up -d --force-recreate`** to apply
   them.

It's running - but only reachable by raw IP. Let's give it a real name.


---

# Domains & DNS

Your app answers at `203.0.113.10` - a number nobody will type or trust. A **domain** turns that number
into `yourproject.com`. The system that maps the name to the number is **DNS**; if the underlying
"names → addresses" machinery is new to you, [IP, DNS & Ports](/guides/ip-dns-and-ports) explains it from
the ground up. Here's the practical sequence to point a fresh domain at your box.

## Buy one, then choose who runs its DNS

Buy a domain from any **registrar**. Cheap and fine for a side project; pick the name, pay, done.

Then decide *who answers DNS queries* for it. Two options:

- **Use the registrar's DNS** - add records right there. Simple.
- **Point the registrar's nameservers at another DNS provider** (very commonly Cloudflare, which is
  [the next phase](05-behind-cloudflare.md)). You change the **nameservers** at the registrar to the two
  the provider gives you; from then on, you manage records at the provider.

```console
$ dig +short NS yourproject.com
amara.ns.cloudflare.com.
rob.ns.cloudflare.com.
```
*What just happened:* `dig` asked which nameservers are authoritative for the domain - here, Cloudflare's.
⚠️ Nameserver changes can take a while to propagate (minutes to a day). If your records "aren't working,"
confirm the nameservers switched *first* - nothing else matters until they have.

## The two records you need: A and CNAME

- **A record** maps a name straight to an **IP address**. Point the apex at your box:
  `yourproject.com → 203.0.113.10`.
- **CNAME** maps a name to **another name** (an alias). Point `www` at the apex:
  `www.yourproject.com → yourproject.com`.

```text
  Type   Name                  Value
  A      yourproject.com       203.0.113.10
  CNAME  www                   yourproject.com
```

## ⚠️ Apex vs www - the apex usually can't be a CNAME

This trips up nearly everyone. The **apex** (also "root" or "naked" domain - `yourproject.com` with no
subdomain) **cannot, by the DNS spec, be a CNAME.** Only an **A** record (or AAAA for IPv6) works at the
apex. So:

- **apex** (`yourproject.com`) → **A record** → your IP.
- **www** (`www.yourproject.com`) → **CNAME** → the apex.

⚠️ Many DNS providers (Cloudflare included) offer **CNAME flattening** that *lets* you put a CNAME-like
record at the apex by resolving it to an IP behind the scenes - handy, but if your provider doesn't,
remember: **apex = A record**. Decide which one is canonical (most pick the apex, redirecting `www` → it,
or vice versa) and be consistent, so links and cookies don't split across two hostnames.

## ⚠️ `.dev` (and `.app`) are HTTPS-only

If you bought a `.dev` domain because it's cute - know this before you lose an hour: **`.dev` and `.app`
are on the browser HSTS preload list, which means browsers *refuse* to load them over plain `http://` at
all.** There's no "I'll add HTTPS later" with a `.dev`; until HTTPS works, the site simply won't open -
not a warning, a hard fail. That's fine, because HTTPS is the very next phase - just don't panic when
`http://yourproject.dev` looks dead. (On a `.com` you'd see it over http first; on `.dev` you won't.)

## Recap

1. **Buy a domain**, then either use the registrar's DNS or **point its nameservers** at a DNS provider
   (often Cloudflare - next phase). Confirm the nameserver switch with `dig NS` before debugging anything
   else.
2. **A record** → name to IP (use it for the **apex**). **CNAME** → name to name (use it for **www**).
3. ⚠️ The **apex can't be a CNAME** - it needs an A record (unless your provider does CNAME flattening).
4. ⚠️ **`.dev`/`.app` won't load over HTTP at all** - they're HTTPS-only, so the site looks broken until
   the next phase is done.

The name resolves to your box. Now make it HTTPS, safe, and fast - with Cloudflare.


---

# Behind Cloudflare

Right now traffic hits your box directly over plain HTTP. Putting **Cloudflare** in front gives you, for
free: HTTPS (a real certificate, auto-renewed), a CDN, and a shield against floods of junk traffic.
Cloudflare sits between your users and your server - users talk to Cloudflare, Cloudflare talks to your
box (the **origin**). (The "what is HTTPS even doing" mental model is [HTTPS & TLS](/guides/https-and-tls);
here we wire it up and dodge the traps.)

There are two ways to connect Cloudflare to your box, and the second is genuinely better.

```mermaid
flowchart LR
  User[user] -->|HTTPS| CF[Cloudflare edge]
  CF -->|proxy: inbound to open ports| Origin[your box]
  CF -->|tunnel: outbound-only conn| Origin
```

## Option A: proxied DNS (the orange cloud)

In Cloudflare's dashboard, your DNS records have a **proxy toggle** (the orange cloud). Turn it on and
Cloudflare stops handing out your real IP - instead, traffic routes *through* Cloudflare, which terminates
HTTPS at its edge and forwards to your origin. Quick to set up. The catch: your origin still needs ports
80/443 open to receive Cloudflare's forwarded traffic - which leads straight to the firewall gotcha below.

## Option B: Cloudflare Tunnel (no open ports - prefer this)

A **Tunnel** flips the direction. You run a small agent (`cloudflared`) on your box that makes an
**outbound** connection to Cloudflare and holds it open. Traffic comes *down* that tunnel - so your server
needs **no inbound ports open at all.** Nothing to port-scan, nothing to firewall, no exposed origin IP.

```console
$ curl -fsSL https://pkg.cloudflare.com/install.sh | sudo bash   # install cloudflared (see Cloudflare docs)
$ cloudflared tunnel login
$ cloudflared tunnel create myproject
$ cloudflared tunnel route dns myproject yourproject.com
$ cloudflared tunnel run myproject
```
*What just happened:* you installed the agent, authenticated it to your Cloudflare account, created a named
tunnel, pointed your domain at it, and ran it. Your app is now reachable at `https://yourproject.com` over
an outbound-only connection - with **zero** inbound ports open on the box. (In production you'd run
`cloudflared` as a service, or as another container in your compose stack.)

💡 **Key point.** Tunnel beats proxy for a side project: free HTTPS *and* the firewall problem below
simply can't exist, because there's nothing listening to the public internet.

## ⚠️ The app-side must-dos: origin, CSRF, and secure cookies

Here's the bug that ruins the victory lap: everything *looks* up, the page loads over HTTPS - but **login
silently fails**, or you get mysterious **403s**. The cause is that your app is now behind a proxy, and it
doesn't know it. Two fixes, both mandatory:

⚠️ **Tell your app its real public origin (for CSRF / redirects).** Your framework sees requests arriving
from Cloudflare, often as `http` on some internal host - not as `https://yourproject.com`. So its CSRF
check ("did this form come from my own site?") compares against the wrong origin and **rejects valid
requests with a 403**, and any "redirect to my URL" sends users somewhere wrong. Set your app's public URL
/ trusted origins explicitly:
```text
# .env
SITE_URL=https://yourproject.com
# many frameworks also need an explicit trusted-origins / allowed-hosts list = yourproject.com
```

⚠️ **Set cookies `Secure` (`COOKIE_SECURE=true`).** The public connection is HTTPS, so your session cookie
must be marked **Secure** - otherwise the browser may refuse to send it back over HTTPS (or your framework
sets it for `http` and the `https` request never sees it), and **the user logs in, gets bounced straight
back to the login page, forever.** A login loop with no error is almost always this.
```text
# .env
COOKIE_SECURE=true
```
*What just happened:* you told the app "your real address is `https://yourproject.com`" (so CSRF and
redirects line up) and "only send session cookies over HTTPS" (so logins stick). These two settings are
invisible locally - on `http://localhost` everything works - and break the moment you're behind HTTPS in
front of a proxy. (Remember from [Phase 3](03-docker-and-your-repo.md): after editing `.env`, you must
`docker compose up -d --force-recreate` - `restart` won't pick these up.)

## ⚠️ Firewall the origin so nobody bypasses Cloudflare

If you went with **Option A (proxy)** and left ports 80/443 open to the whole internet, you have a hole:
anyone who discovers your origin IP can hit the box **directly**, skipping Cloudflare's HTTPS and
protection entirely. All that edge security becomes optional for an attacker.

Lock the origin so it *only* accepts traffic from Cloudflare:

```console
$ sudo ufw default deny incoming
$ sudo ufw allow 22/tcp                 # keep your SSH in!
# then allow 80/443 ONLY from Cloudflare's published IP ranges (see their docs), e.g.:
$ sudo ufw allow from 173.245.48.0/20 to any port 443 proto tcp
$ sudo ufw enable
```
*What just happened:* the box now refuses inbound traffic except SSH and HTTPS-from-Cloudflare, so the
origin can't be reached directly. ⚠️ **Allow SSH (22) before you `enable`**, or you'll firewall yourself
out. And with **Option B (Tunnel)** this whole section is moot - there are no inbound ports to lock,
which is the entire reason a tunnel is the cleaner choice.

## Recap

1. **Cloudflare = free HTTPS + protection**, sitting between users and your origin.
2. **Proxy (orange cloud)** is quick but keeps origin ports open; **Tunnel (`cloudflared`)** needs **no
   inbound ports** - prefer it.
3. ⚠️ Behind a proxy, set **`SITE_URL` / trusted origins** (or CSRF gives 403s) and **`COOKIE_SECURE=true`**
   (or logins loop) - then `--force-recreate` to apply them.
4. ⚠️ With the proxy, **firewall the origin to Cloudflare's IPs** so nobody hits the box directly (allow
   SSH first!). With a Tunnel, there's nothing to firewall.

It's live, HTTPS, and safe. The last step is making *future* changes effortless.


---

# Auto-Deploy on Merge

Right now, shipping a change means: SSH in, `git pull`, recreate the containers, by hand, every time.
That's fine once; it's friction forever, and friction is where bugs and stale deploys live. The finish
line is **merge to `main` → it's live**, automatically. We'll use GitHub Actions; for how pipelines work
in general, [Your First Pipeline (GitHub Actions)](/guides/your-first-pipeline-github-actions) and
[What a CI/CD Pipeline Actually Does](/guides/what-cicd-does) are the foundations - here's the deploy-over-SSH
shape specifically.

## The shape: push → Action → SSH → pull + recreate

```mermaid
flowchart LR
  Merge[merge to main] --> Action[GitHub Actions] --> SSH[SSH into the box] --> Deploy[git pull + compose up -d]
```

On every push to `main`, a workflow runs on GitHub's machines, opens an SSH connection to your box using a
key you've stored as a **secret**, and runs the same commands you'd type by hand.

## The deploy key and the secret

Give the Action its own way in (never your personal key):

1. Generate a **deploy SSH key pair** for CI. Put the **public** half in the box's `~/.ssh/authorized_keys`
   (so the Action can log in), and store the **private** half in **GitHub → repo → Settings → Secrets and
   variables → Actions** as, say, `DEPLOY_SSH_KEY`. Add `DEPLOY_HOST` and `DEPLOY_USER` too.
2. Secrets are encrypted and injected at run time - never printed, never committed.

## The workflow

```yaml
# .github/workflows/deploy.yml
name: Deploy
on:
  push:
    branches: [main]      # only merges/pushes to main deploy

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: SSH in and update the stack
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            cd ~/your-project
            git pull
            docker compose up -d --force-recreate
```
*What just happened:* on a push to `main`, GitHub spins up a runner, connects to your box with the secret
key, and runs the deploy script - `git pull` to fetch the merged code, then `docker compose up -d
--force-recreate` to bring the stack up on it. From now on, **merging is deploying.** No SSH, no
remembering the commands.

## ⚠️ The two traps, one last time

⚠️ **`--force-recreate`, not `restart`.** The script uses `docker compose up -d --force-recreate` on
purpose. If it ran `docker compose restart`, a deploy that only changed an env value or rebuilt an image
would **silently keep running the old containers** - the Action goes green, and nothing actually changed.
This is the [Phase 3](03-docker-and-your-repo.md) trap, now automated: recreate, don't restart.

⚠️ **Don't build on the tiny box if you can help it.** `git pull` + `docker compose up -d --build` builds
*on the server* - straight back into the [Phase 1](01-pick-a-vps.md) OOM risk, now on every deploy. Two
better shapes:
- **Build in the Action, pull the image on the box.** The runner has plenty of RAM: build the image there,
  push it to a registry, and have the box's compose file just *pull* it (`docker compose pull && up -d`).
  No build on the box, ever.
- **Or keep building on the box but guarantee swap** ([Phase 1](01-pick-a-vps.md)) so the build can't get
  OOM-killed mid-deploy.

💡 **Key point.** A deploy should be boring. The recipe - pull, recreate, prefer a pre-built image - makes
every future change a merge and nothing more.

## You shipped it

Step back at what you built across these six phases: a box you chose wisely and won't get surprise-billed
for, a locked-down SSH key login, your private app running in Docker, a real domain, free HTTPS behind
Cloudflare with the cookie/CSRF/firewall traps handled - and now a one-merge deploy. The gap between "works
on my laptop" and "a stranger uses it at a URL" is closed, and you know where every trap was hiding.

The next time someone says "just deploy it," that word won't make your stomach drop. You've done the whole
journey once, with eyes open - and that's the only time it's ever hard.

## Recap

1. **GitHub Actions on push to `main`** SSHes into the box (with a **deploy key in Secrets**) and runs
   your deploy script - merging becomes deploying.
2. ⚠️ Use **`docker compose up -d --force-recreate`**, not `restart`, or deploys silently keep the old
   containers.
3. ⚠️ **Avoid building on the tiny box** - build in the Action and **pull the image**, or guarantee
   **swap** so the deploy build can't OOM.
