# Deploying to a VPS (From Zero to Live)

> How to get your app onto a rented Linux box and reachable on the real internet - from spinning up the server and SSHing in, to running your app as a service that survives crashes and reboots, to a domain, a reverse proxy, and HTTPS.


---

# Deploying to a VPS (From Zero to Live)

Your app runs on your laptop. It works. And now you need it to run *somewhere else* - somewhere that's
on all night, that strangers can reach at a real URL, that doesn't vanish when you close the lid. That
gap, between "works on my machine" and "live on the internet," is where a lot of otherwise-confident
developers freeze. Not because it's hard, but because nobody ever walked them through the whole arc:
the box, getting in, keeping the app alive, the domain, and the lock icon in the browser.

This guide is that walkthrough. By the end you'll have rented a Linux box, hardened it just enough to
sleep at night, run your app as a proper background service that restarts itself, pointed a domain at
it, put a reverse proxy in front, and earned a real HTTPS certificate. From zero to a real URL.

## How to read this

- **Want it to finally make sense?** Read in order. Each phase hands the next one a working server,
  and the mental models stack: a box you can reach → an app that stays up → a public, safe front door.
- **Already have a box and SSH access?** Skip to [Phase 2: Run Your App as a Service](02-run-your-app-as-a-service.md).
- **App's running but the world can't reach it?** Go straight to
  [Phase 3: Make It Public & Safe](03-make-it-public-and-safe.md).

This is an intermediate guide. It assumes you're comfortable in a terminal and have an app that runs
locally. It does *not* assume you've ever touched a server.

## The phases

1. **[Get a Box and Get In](01-get-a-box-and-get-in.md)** - what a VPS actually is, renting one, your
   first SSH login, and the three pieces of first-login hardening (a non-root user, updates, a firewall)
   that everyone skips and later regrets.
2. **[Run Your App as a Service](02-run-your-app-as-a-service.md)** - getting your code onto the box,
   running it, and then handing it to **systemd** so it restarts on crash, comes back after a reboot, and
   doesn't die when you close your terminal. Plus how to confirm it's actually listening on its port.
3. **[Make It Public & Safe](03-make-it-public-and-safe.md)** - pointing a domain at the box with a DNS
   **A record**, putting **nginx** in front as a reverse proxy, and getting a free **HTTPS** certificate
   from Let's Encrypt - without exposing your app port directly to the world.

> This guide gets you to a single live box running your app behind HTTPS. Multi-server setups, zero-
> downtime deploys, containers, and CI/CD pipelines are bigger topics that build on this foundation -
> they each deserve their own guide rather than a rushed paragraph here.

**Related guides:** [SSH and Keys](/guides/ssh-and-keys) · [Linux for Servers](/guides/linux-for-servers) ·
[Load Balancers and nginx](/guides/load-balancers-and-nginx)


---

# Get a Box and Get In

Before you can deploy anything, you need a *somewhere* to deploy it - a computer that isn't your laptop,
that's on all the time, and lives on the public internet. You're going to rent one, and the moment you
do, you're handed something weirdly powerful and a little alarming: full root access to a Linux machine
that anyone in the world can try to reach. Let's fix the mental model first, since almost everything in
this phase follows from understanding what you're actually renting.

## What a VPS actually is

A **VPS** - *Virtual Private Server* - is a slice of a big physical server in a data center, carved out
and handed to you as if it were a whole computer of its own: its own OS (you pick the Linux
distribution), its own memory and disk, its own public IP, and a root login that's yours alone. "Virtual"
means a hypervisor splits one physical machine into many isolated ones; "private" means your slice is
walled off from everyone else's.

The common misconception is that a VPS is some managed "hosting" thing with a control panel that does
everything for you. It isn't - it's a bare Linux box. Nobody installs your app, configures your web
server, or sets up security for you; you get an empty machine and root access, and the rest is up to you.
That's the deal: total control, total responsibility. It sits in a data center, powered on, connected to
the internet, running whatever you tell it to - administered entirely over the network via SSH (headless,
no monitor or keyboard; see [Linux for Servers](/guides/linux-for-servers) for the full server mindset).
You pay by the month, usually a few dollars for a small one, and can destroy and recreate it anytime.

📝 **Terminology.** *Provider* = the company renting you the box (DigitalOcean, Hetzner, Linode/Akamai,
Vultr, AWS Lightsail, and others). *Instance* / *droplet* / *server* = your individual VPS - providers
each use their own word. *Distribution* (*distro*) = the flavor of Linux; this guide uses Ubuntu, the
most common starting point with widely documented commands.

## Creating the box

There's no single command for this part - you do it in your provider's web console. The specifics differ
between providers, but every one asks the same handful of questions, and knowing what each one means
saves you from guessing:

```text
   WHAT THE SIGNUP FORM ASKS          WHAT IT MEANS / WHAT TO PICK
   ─────────────────────────────      ─────────────────────────────────────────────
   Operating system / image      │    The distro. Pick Ubuntu LTS (a "long-term
                                 │    support" release - stable, supported for years).
   Size / plan                   │    How much CPU/RAM/disk. The smallest tier is fine
                                 │    to start; you can resize later.
   Region / datacenter           │    Where the box physically lives. Pick the one
                                 │    closest to your users for lower latency.
   Authentication                │    SSH key (strongly preferred) or password.
                                 │    Choose SSH key - see below.
   Hostname                      │    A name for the box, e.g. "web-prod-1". Cosmetic.
```

The one choice that matters for everything after is **authentication**. When the form offers "SSH key"
versus "password," choose the SSH key, and paste yours in if it lets you. With a key, the provider puts
it on the box before it even boots, so your first login needs no password and the box is far harder to
brute-force. No keypair yet? Generating one is its own essential skill - see
[SSH and Keys](/guides/ssh-and-keys) before you continue; it's worth the ten-minute detour.

When you finish, the provider boots the box and shows you its **public IP address** - something like
`203.0.113.10`. Write it down. That number is how you reach your server until you give it a domain name
in [Phase 3](03-make-it-public-and-safe.md).

## Your first login

SSH (*Secure Shell*) gives you a terminal on the remote box exactly as if you were sitting in front of
it, with everything between you and it encrypted - you type locally, the commands run *there*. On a
fresh VPS, the account waiting for you is usually `root`, the all-powerful administrator.

With your key in place, the first connect is one command:

```console
$ ssh root@203.0.113.10
The authenticity of host '203.0.113.10 (203.0.113.10)' can't be established.
ED25519 key fingerprint is SHA256:Z9k3...Qp1A.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '203.0.113.10' (ED25519) to the list of known hosts.
Welcome to Ubuntu 24.04.1 LTS (GNU/Linux 6.8.0-41-generic x86_64)

root@web-prod-1:~#
```

*What just happened:* `ssh root@203.0.113.10` opens a shell on that host as `root`. Since this was the
first connection, SSH showed the server's **host key fingerprint** and asked you to confirm it - proof of
*which* machine you're talking to, so nobody can impersonate it later. Typing `yes` remembered the key,
and the prompt changed to `root@web-prod-1:~#`; that `#` (instead of `$`) is the tell that you're root,
running commands with full privileges. `exit` drops you back to your laptop.

⚠️ **Gotcha.** That fingerprint prompt only happens *once* per host. If you ever reconnect and SSH
instead prints `WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!`, do **not** blindly type `yes` - it
means the host key differs from the one you trusted, usually an innocent server rebuild but potentially
someone intercepting you. Confirm out-of-band why it changed before clearing the old entry from
`~/.ssh/known_hosts`. Full treatment in [SSH and Keys](/guides/ssh-and-keys).

## First-login hardening: the three things everyone skips

Here's the uncomfortable truth about a fresh, internet-facing box: within *minutes* of booting, automated
bots start probing it, trying common usernames and passwords against your SSH port. That's not paranoia,
it's the constant background noise of the internet. Before you install anything, spend ten minutes on
three pieces of hardening - not optional, and far easier now than cleaning up after they bite you.

### 1. Create a non-root user (and stop living as root)

Root can do *anything* - including, with one mistyped command, deleting the whole system. The fix: do
your day-to-day work as an ordinary user who has to deliberately ask for admin powers (via `sudo`) for
the dangerous stuff. That deliberate ask is a speed bump that's saved countless servers from a careless
`rm`.

Still logged in as root, create a user and give them `sudo` rights:

```console
root@web-prod-1:~# adduser deploy
Adding user `deploy' ...
Adding new group `deploy' (1000) ...
Adding new user `deploy' (1000) with group `deploy' ...
Creating home directory `/home/deploy' ...
Copying files from `/etc/skel' ...
New password:
Retype new password:
passwd: password updated successfully
Changing the user information for deploy
Enter the new value, or press ENTER for the default
	Full Name []:
... (press Enter through the rest) ...
Is the information correct? [Y/n] Y

root@web-prod-1:~# usermod -aG sudo deploy
```

*What just happened:* `adduser deploy` created a normal user with its own home directory at
`/home/deploy` and set a password (used when `sudo` asks you to confirm). `usermod -aG sudo deploy` then
*appended* (`-a`) `deploy` to the `sudo` *group* - on Ubuntu, membership in that group is what grants the
right to run commands as root via `sudo`.

⚠️ **Gotcha.** Don't forget the `-a` in `usermod -aG`. Without it, `usermod -G sudo deploy` *replaces*
all the user's groups with just `sudo`, quietly stripping every other group they belonged to. `-a` means
"append, not replace" - this trips people up constantly.

Now copy your SSH key to the new user, from your **laptop**:

```console
$ ssh-copy-id deploy@203.0.113.10
```

*What just happened:* `ssh-copy-id` appended your public key to `/home/deploy/.ssh/authorized_keys`, so
the box now recognizes your laptop's key for the `deploy` user. (No `ssh-copy-id`? The manual equivalent
is in [SSH and Keys](/guides/ssh-and-keys).) Test it:

```console
$ ssh deploy@203.0.113.10
Welcome to Ubuntu 24.04.1 LTS (GNU/Linux 6.8.0-41-generic x86_64)

deploy@web-prod-1:~$ sudo whoami
[sudo] password for deploy:
root
```

*What just happened:* You logged in as `deploy` (the `$` prompt - an ordinary user, not root), then ran
`sudo whoami` to confirm admin rights work: `sudo` asked for `deploy`'s password, ran `whoami` as root,
and printed `root`. From here on, live as `deploy` and reach for `sudo` only when a command genuinely
needs admin power.

💡 **Key point.** Once `deploy` can log in *and* use `sudo`, you can disable direct root SSH login and
password-based login entirely - covered in [SSH and Keys](/guides/ssh-and-keys). Just never disable root
*until* you've verified your replacement login works, or you'll lock yourself out.

### 2. Update the system

The OS and its packages shipped with whatever versions existed when the image was built - possibly weeks
or months old, including known security holes. Updating pulls the current, patched versions.

Two commands, run as `deploy`:

```console
deploy@web-prod-1:~$ sudo apt update
Hit:1 http://archive.ubuntu.com/ubuntu noble InRelease
Get:2 http://security.ubuntu.com/ubuntu noble-security InRelease [129 kB]
...
Reading package lists... Done
42 packages can be upgraded. Run 'apt list --upgradable' to see them.

deploy@web-prod-1:~$ sudo apt upgrade -y
Reading package lists... Done
Building dependency tree... Done
...
The following packages will be upgraded:
  ... (list of packages) ...
Setting up ...
```

*What just happened:* `apt update` refreshed the local catalog of *what versions are available* (it
changes nothing installed yet, just learns what's out there). `apt upgrade -y` then downloaded and
installed the newer versions, with `-y` auto-answering the confirmation prompt. `apt` is Ubuntu's package
manager - it installs, updates, and removes software.

⚠️ **Gotcha.** If an upgrade touches the kernel (watch for a note about a pending reboot, or
`/var/run/reboot-required`), it only takes effect after `sudo reboot`. That drops your SSH connection for
a minute or two - wait, then reconnect. Normal, not a sign you broke anything.

### 3. Turn on a firewall

A firewall decides which network ports the world is allowed to reach. A fresh box may have several
services listening by default; you want it to accept connections on *only* the ports you intend to
serve. On Ubuntu the friendly front-end is **UFW** (*Uncomplicated Firewall*).

Allow SSH (so you don't lock yourself out), allow web traffic, then enable it:

```console
deploy@web-prod-1:~$ sudo ufw allow OpenSSH
Rules updated
Rules updated (v6)
deploy@web-prod-1:~$ sudo ufw allow 80/tcp
Rules updated
Rules updated (v6)
deploy@web-prod-1:~$ sudo ufw allow 443/tcp
Rules updated
Rules updated (v6)
deploy@web-prod-1:~$ sudo ufw enable
Command may disrupt existing ssh connections. Proceed with operation (y|n)? y
Firewall is active and enabled on system startup
deploy@web-prod-1:~$ sudo ufw status
Status: active

To                         Action      From
--                         ------      ----
OpenSSH                    ALLOW       Anywhere
80/tcp                     ALLOW       Anywhere
443/tcp                    ALLOW       Anywhere
OpenSSH (v6)               ALLOW       Anywhere (v6)
80/tcp (v6)                ALLOW       Anywhere (v6)
443/tcp (v6)               ALLOW       Anywhere (v6)
```

*What just happened:* UFW allowed three things before turning on. `OpenSSH` is a named rule for port 22 -
allowing it **first** is critical, since enabling a firewall that blocks SSH locks you out. `80/tcp` and
`443/tcp` are HTTP/HTTPS, needed once nginx fronts the app in [Phase 3](03-make-it-public-and-safe.md).
`ufw enable` then activated the firewall (it warns that it *could* disrupt SSH, but you're safe since
OpenSSH is allowed). `ufw status` confirms exactly what's open: SSH and web, nothing else.

⚠️ **Gotcha.** Always `allow OpenSSH` *before* you `enable`. The single most common way people lock
themselves out of a fresh box is enabling a firewall that blocks port 22. Most providers offer a
web-based console (separate from SSH) to fix it if it happens - but far better to never need it.

💡 **Key point.** Notice what you did *not* open: your app's own port (say, 3000 or 8080). That's
deliberate - it's the whole strategy of [Phase 3](03-make-it-public-and-safe.md): your app listens
privately, and only nginx (on 80/443) is exposed to the world. Never open your raw app port to the
internet.

## Recap

1. A **VPS** is a rented slice of a Linux server - your own headless box with a public IP and root
   access. Total control, total responsibility; nobody sets it up for you.
2. You create it in the provider's console, choosing **Ubuntu LTS**, the smallest size to start, a
   nearby region, and - crucially - **SSH key** authentication.
3. You reach it with **`ssh root@<ip>`**, verifying the host key fingerprint on first connect.
4. Before anything else, do the three hardening steps: a **non-root `sudo` user** you'll live as,
   **`apt update && apt upgrade`** to patch the system, and **UFW** allowing only SSH and web ports -
   SSH allowed *first*.

You now have a clean, reachable, reasonably-locked-down box. Next, let's get your app onto it and make
it stay running.


---

# Run Your App as a Service

You've got a box. Now you need your app *on* it - and, more importantly, *staying* on it. Here's the trap
everyone falls into the first time: you SSH in, start your app, see it working, close your laptop... and
it dies. Or it crashes at 3am and nobody restarts it. Or the box reboots for a kernel update and it never
comes back.

The fix isn't discipline or luck - it's handing your app to the part of Linux whose entire job is keeping
long-running programs alive: **systemd**. This phase gets your code onto the box, runs it once by hand to
prove it works, then makes it a real service, starting with one fact about your shell that explains why
this whole phase exists.

## Why "just run it" doesn't work

When you start a program in your SSH session, that program is a *child* of your shell, and your shell is
tied to your *connection*. When the connection ends - you log out, your laptop sleeps, the network
blips - the shell goes away and takes your program down with it.

```text
   RUNNING IT BY HAND                      RUNNING IT AS A SERVICE
   ─────────────────────────────          ─────────────────────────────
   tied to your SSH session         │      owned by systemd (the init system)
   dies when you log out            │      runs unattended for months
   stays dead if it crashes         │      restarted automatically on crash
   gone after a reboot              │      started again on every boot
   logs scroll past and vanish      │      logs captured in the journal
```

A server's whole job is to keep services alive without a human watching (see
[Linux for Servers](/guides/linux-for-servers) for the full picture) - a program tied to your terminal is
the opposite of that. So we run it by hand *once*, only to confirm it works, then hand it off to
something that won't let it die.

## Step 1: Get your code onto the box

You have two clean ways to move your app from your laptop to the server. Pick the one that fits.

**Option A - clone from Git**, the usual choice. If your code is in a Git repository, install Git on the
box and clone it:

```console
deploy@web-prod-1:~$ sudo apt install -y git
...
deploy@web-prod-1:~$ git clone https://github.com/you/your-app.git
Cloning into 'your-app'...
remote: Enumerating objects: 348, done.
remote: Counting objects: 100% (348/348), done.
...
Receiving objects: 100% (348/348), 1.21 MiB | 4.30 MiB/s, done.
Resolving deltas: 100% (180/180), done.
deploy@web-prod-1:~$ cd your-app
```

*What just happened:* You installed Git, then `git clone` downloaded a full copy of your repository into
`/home/deploy/your-app`. Updating later is `git pull` from inside that directory. For a private repo
you'll need to authenticate - a deploy key or token - which the repo host documents.

**Option B - copy files directly with `scp`**, if your app isn't in Git or is a built artifact (a
compiled binary, a bundled archive). From your **laptop**:

```console
$ scp ./my-app deploy@203.0.113.10:/home/deploy/
my-app                              100%   18MB  6.1MB/s   00:03
```

*What just happened:* `scp` (*secure copy*, SSH's file-transfer cousin) pushed the local file `./my-app`
to `/home/deploy/` on the server. It reads `destination:path` the same way `ssh` reads `user@host`. The
`100%` line is the transfer completing.

Once the code is on the box, install whatever runtime and dependencies it needs (Node, Python, a JVM,
nothing at all for a static binary) the same way you would anywhere - with `apt` or your language's own
tooling. That part is specific to your stack, so we won't guess at it here.

## Step 2: Run it once, by hand, to prove it works

Before automating anything, run the app directly and watch it start. You want to *see* it come up and
confirm the port it listens on.

```console
deploy@web-prod-1:~/your-app$ ./my-app
Starting server...
Listening on http://127.0.0.1:3000
```

*What just happened:* Your app started in the foreground and reported listening on port `3000` - note
the address, `127.0.0.1` (*localhost*), reachable only from the box itself. That's exactly what you want
for now; the outside world will reach it through nginx in [Phase 3](03-make-it-public-and-safe.md), not
directly.

Leave it running and open a **second** SSH session to confirm it actually answers:

```console
deploy@web-prod-1:~$ curl http://127.0.0.1:3000
<!DOCTYPE html><html><head><title>My App</title>...
```

*What just happened:* `curl` made an HTTP request to your app from inside the box and got the response
body back - proof the app is up and serving. Now go back to the first session and stop it with
**Ctrl-C**. You've confirmed it works; time to make it permanent.

📝 **Terminology.** *localhost* / `127.0.0.1` = the box talking to itself; connections to this address
never leave the machine. *Binding* = which address and port a server listens on. An app bound to
`127.0.0.1:3000` is private to the box; one bound to `0.0.0.0:3000` would accept connections from
anywhere - which, for your app port, you do **not** want (more on that in Phase 3).

## Step 3: Hand it to systemd

**systemd** is the *init system* on modern Ubuntu - the very first process to start at boot (PID 1),
responsible for starting, stopping, supervising, and restarting all the long-running services on the box.
You describe your app to it in a small text file called a **unit file**, and from then on systemd treats
your app exactly like it treats SSH or nginx: a service it keeps alive.

Create the unit file with `sudo` (it lives in a system directory):

```console
deploy@web-prod-1:~$ sudo nano /etc/systemd/system/my-app.service
```

Put this inside (adjust the paths and command for your app):

```text
[Unit]
Description=My App
After=network.target

[Service]
User=deploy
WorkingDirectory=/home/deploy/your-app
ExecStart=/home/deploy/your-app/my-app
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target
```

Here's what each line is telling systemd, because copy-pasting a unit file you don't understand is how
mysteries get created:

- **`Description`** - a human-readable label shown in status output.
- **`After=network.target`** - don't start my app until the network is up.
- **`User=deploy`** - run the app as the `deploy` user, *not* root. This matters: a service should run
  with the least privilege it needs, so a bug in your app can't trivially become a bug in your whole box.
- **`WorkingDirectory`** - the directory to run from (so relative paths in your app resolve correctly).
- **`ExecStart`** - the exact command to launch your app. Use the **full absolute path**; systemd
  doesn't use your shell's `PATH`.
- **`Restart=always`** with **`RestartSec=3`** - if the app exits for *any* reason, wait 3 seconds and
  start it again. This is the line that turns "it crashed and stayed down" into "it crashed and recovered."
- **`WantedBy=multi-user.target`** - when enabled, start this at boot, once the system reaches normal
  multi-user operation.

Save and exit (in `nano`: Ctrl-O, Enter, Ctrl-X). Now tell systemd about it, start it, and set it to run
on boot:

```console
deploy@web-prod-1:~$ sudo systemctl daemon-reload
deploy@web-prod-1:~$ sudo systemctl enable --now my-app
Created symlink /etc/systemd/system/multi-user.target.wants/my-app.service → /etc/systemd/system/my-app.service.
```

*What just happened:* `daemon-reload` told systemd to re-read its unit files so it notices the new one.
Then `enable --now my-app` did two things at once: **`enable`** wired the service to start automatically
on every boot (that's the symlink it reports creating), and **`--now`** also started it immediately. Your
app is running - and this time it isn't tied to your session at all.

## Step 4: Confirm it's alive (and check it's listening)

Always verify, don't assume. First, ask systemd how the service is doing:

```console
deploy@web-prod-1:~$ sudo systemctl status my-app
● my-app.service - My App
     Loaded: loaded (/etc/systemd/system/my-app.service; enabled; preset: enabled)
     Active: active (running) since Fri 2026-06-19 14:22:07 UTC; 12s ago
   Main PID: 8123 (my-app)
      Tasks: 7 (limit: 1131)
     Memory: 24.5M
        CPU: 180ms
     CGroup: /system.slice/my-app.service
             └─8123 /home/deploy/your-app/my-app

Jun 19 14:22:07 web-prod-1 systemd[1]: Started my-app.service - My App.
Jun 19 14:22:07 web-prod-1 my-app[8123]: Listening on http://127.0.0.1:3000
```

*What just happened:* The two lines that matter are **`Active: active (running)`** (the app is up) and
**`enabled`** (it'll come back after a reboot). You can also see its process ID, memory use, and the last
few log lines - including your app's own "Listening on..." message, which systemd captured for you.

Second, confirm the app is genuinely listening on its port:

```console
deploy@web-prod-1:~$ sudo ss -tlnp | grep 3000
LISTEN 0      511        127.0.0.1:3000       0.0.0.0:*    users:(("my-app",pid=8123,fd=6))
```

*What just happened:* `ss -tlnp` lists listening (`-l`) TCP (`-t`) sockets with numeric addresses (`-n`)
and the owning process (`-p`). The line shows your app (`my-app`, PID 8123) listening on
`127.0.0.1:3000` - bound to localhost, exactly as intended. (`ss` is the modern replacement for the
older `netstat`; if a tutorial shows `netstat -tlnp`, this is its equivalent.)

To watch logs live - the server-world replacement for output that used to scroll past in your terminal -
use the journal:

```console
deploy@web-prod-1:~$ sudo journalctl -u my-app -f
Jun 19 14:22:07 web-prod-1 my-app[8123]: Listening on http://127.0.0.1:3000
Jun 19 14:25:31 web-prod-1 my-app[8123]: GET / 200 14ms
```

*What just happened:* `journalctl -u my-app` shows the captured logs *for this unit* (`-u`), and `-f`
*follows* them - new lines appear as they happen, like `tail -f`. Press Ctrl-C to stop watching (it
doesn't stop the app - just your view of its logs). This is where you'll look first whenever something
seems wrong.

🪖 **War story.** The classic way to *think* you've deployed but haven't: you start the app in a plain
SSH session, it works, you close the laptop, and the demo dies five minutes into the meeting. The whole
point of the systemd dance above is that the app's life is no longer connected to yours - verify
`active (running)` and `enabled`, then disconnect with confidence.

⚠️ **Gotcha.** If `systemctl status` shows `failed` or `activating (auto-restart)` flapping, your
`ExecStart` command, a path, or a missing dependency is almost always the cause. Read `journalctl -u
my-app` - your app's own startup error will be sitting right there. Don't keep re-running `systemctl
restart` hoping it'll catch; read the log and fix the actual error.

## Recap

1. A program started in your SSH session **dies with the session** - that's why "just run it" fails.
2. Get your code on the box with **`git clone`** (or **`scp`** for an artifact), then run it **once by
   hand** to confirm it works and see its port - bound to **`127.0.0.1`**, private to the box.
3. Describe it to **systemd** in a unit file: run as a **non-root user**, **`Restart=always`** so it
   recovers from crashes, and **`WantedBy=multi-user.target`** so it returns after a reboot.
4. **`systemctl enable --now`** starts it and wires it to boot; verify with **`systemctl status`**
   (look for `active (running)` *and* `enabled`), check the port with **`ss -tlnp`**, and read logs with
   **`journalctl -u my-app`**.

Your app now stays up on its own - but it's still hiding on localhost where nobody outside the box can
reach it. Next, we open the front door: a domain, a reverse proxy, and HTTPS.


---

# Make It Public & Safe

Your app is running and self-healing, but it's hiding on `127.0.0.1:3000`, reachable only from the box
itself. The internet can see your server's IP, but not your app. This phase opens the front door - the
right way - so a real person can type a real domain into a browser and get your app over a secure HTTPS
connection. There are three moving parts, and they fit together in a clean chain - let's see the whole
picture before touching anything, since each piece only makes sense in light of the others.

## The whole picture first

A request from a visitor's browser will travel like this:

```mermaid
flowchart TD
  Browser["browser<br/>your-domain.com"]
  DNS["DNS<br/>resolves name → IP"]
  Nginx["nginx (public)<br/>reverse proxy - terminates HTTPS,<br/>forwards inward"]
  App["127.0.0.1:3000<br/>your app (private)"]
  Browser -->|"your-domain.com"| DNS
  DNS -->|"203.0.113.10, port 443 (HTTPS)"| Nginx
  Nginx -->|"plain HTTP, on the box only"| App
```

Three jobs, three tools:

1. **DNS** turns the human name `your-domain.com` into your box's IP address `203.0.113.10`.
2. **nginx** sits at the public edge on ports 80/443, accepts the visitor's connection, and *forwards*
   it to your app on localhost. This is a **reverse proxy**.
3. **HTTPS** (via a Let's Encrypt certificate) encrypts the connection between the browser and nginx, so
   the browser shows the padlock instead of "Not secure."

The crucial idea - the reason this is *safe* and not just *working* - is that your app never faces the
internet directly. Only nginx does. Your app stays bound to localhost behind it.

## Step 1: Point a domain at the box (DNS)

**DNS** (*Domain Name System*) is the internet's address book: it maps names people can remember
(`your-domain.com`) to the IP addresses machines actually use (`203.0.113.10`). To point your domain at
your box, you add one record - an **A record** - at wherever you bought or manage the domain (your
registrar or DNS host).

📝 **Terminology.** *A record* = a DNS entry mapping a name to an IPv4 address. (*AAAA record* is the
same for an IPv6 address; add one too if your box has an IPv6 address.) *TTL* = "time to live," how long
resolvers are allowed to cache the answer.

In your DNS provider's dashboard, create an A record. The form maps onto these fields:

```text
   FIELD       TYPICAL VALUE              MEANS
   ─────       ─────────────              ──────────────────────────────────
   Type        A                          maps a name to an IPv4 address
   Name/Host   @  (or "www")              @ = the bare domain; "www" = www.…
   Value       203.0.113.10               your box's public IP
   TTL         3600 (or "Auto")           cache lifetime, in seconds
```

`@` means the root of your domain (`your-domain.com` itself); add a second record with name `www` if you
want `www.your-domain.com` to work too.

DNS changes propagate gradually, so don't panic if it's not instant. Confirm it took effect from your
laptop:

```console
$ dig +short your-domain.com
203.0.113.10
```

*What just happened:* `dig +short` asked the DNS system "what's the A record for `your-domain.com`?" and
got back your box's IP - meaning the name now points at your server. If it returns nothing or an old
address, the change hasn't propagated yet; wait a bit (minutes to an hour, depending on TTL) and try
again. Don't move on to the certificate step until this resolves correctly, because the certificate
process relies on the domain reaching your box.

## Step 2: Put nginx in front (reverse proxy)

A **reverse proxy** is a server that sits in front of your app, receives requests from the outside world,
and passes them along to your app behind it - then relays the app's response back out. **nginx** is the
most common one. It handles the messy public-facing parts (TLS, multiple sites, large numbers of
connections) so your app can stay simple and private.

**Why a reverse proxy at all?** Three concrete reasons: it lets you terminate HTTPS in one well-tested
place instead of in your app; it lets one box serve multiple apps/domains on the same ports; and it keeps
your app off the public internet entirely. (nginx does much more - caching, load balancing across several
app instances - the subject of [Load Balancers and nginx](/guides/load-balancers-and-nginx). Here we use
just the proxy piece.)

Install nginx:

```console
deploy@web-prod-1:~$ sudo apt install -y nginx
...
deploy@web-prod-1:~$ sudo systemctl status nginx
● nginx.service - A high performance web server and a reverse proxy server
     Active: active (running) since Fri 2026-06-19 14:40:11 UTC; 4s ago
```

*What just happened:* `apt` installed nginx, which on Ubuntu starts and enables itself on boot - note
it's already `active (running)`. (You allowed ports 80 and 443 through UFW back in
[Phase 1](01-get-a-box-and-get-in.md), so it's reachable.) Visiting `http://your-domain.com` now would
show nginx's default welcome page - proof the public path works, before we point it at your app.

Now create a site config that forwards traffic to your app. Make a new file:

```console
deploy@web-prod-1:~$ sudo nano /etc/nginx/sites-available/my-app
```

Put this inside:

```text
server {
    listen 80;
    server_name your-domain.com www.your-domain.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
```

What this says, line by line:

- **`listen 80`** - accept HTTP on port 80. (Certbot will add the HTTPS/443 piece for you in Step 3.)
- **`server_name`** - this block handles requests for these domain names.
- **`location /`** - for every path on the site...
- **`proxy_pass http://127.0.0.1:3000`** - ...forward the request to your app on localhost port 3000.
  This is the reverse-proxy heart of the file.
- **The `proxy_set_header` lines** - pass along useful information your app would otherwise lose behind
  the proxy: the original `Host`, the visitor's real IP, and whether the original request was HTTP or
  HTTPS. Many frameworks need these to build correct links and log real client addresses.

Enable the site and reload nginx:

```console
deploy@web-prod-1:~$ sudo ln -s /etc/nginx/sites-available/my-app /etc/nginx/sites-enabled/
deploy@web-prod-1:~$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
deploy@web-prod-1:~$ sudo systemctl reload nginx
```

*What just happened:* `ln -s` created a symlink from `sites-available` (where configs live) into
`sites-enabled` (the ones nginx actually loads) - that two-directory pattern is how nginx lets you keep a
config on disk without it being live. **`nginx -t`** tested the configuration for syntax errors *before*
applying it (always run this - a broken config that you reload can take the site down). With the test
passing, `systemctl reload nginx` applied it without dropping existing connections. Now
`http://your-domain.com` reaches your app.

⚠️ **Gotcha - the one that's easy to get wrong.** Do **not** open your app's port (3000) in the
firewall, and do **not** bind your app to `0.0.0.0`. Either mistake lets people skip nginx entirely and
hit your app directly - bypassing HTTPS, any access rules, and the whole point of the proxy. Your app
stays on `127.0.0.1`; only nginx (ports 80/443) is public. That separation *is* the safety.

## Step 3: Turn on HTTPS with Let's Encrypt

**HTTPS** is HTTP wrapped in encryption (**TLS**), so nobody between the visitor and your server can read
or tamper with the traffic. It requires a **certificate** - a file, signed by a trusted authority, that
proves you control the domain. **Let's Encrypt** is a free, automated certificate authority, and
**Certbot** is the tool that gets and installs its certificates for you. The browser padlock comes from
this.

Install Certbot's nginx plugin and run it:

```console
deploy@web-prod-1:~$ sudo apt install -y certbot python3-certbot-nginx
...
deploy@web-prod-1:~$ sudo certbot --nginx -d your-domain.com -d www.your-domain.com
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Enter email address (used for urgent renewal and security notices): you@example.com
...
Requesting a certificate for your-domain.com and www.your-domain.com

Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/your-domain.com/fullchain.pem
Key is saved at:         /etc/letsencrypt/live/your-domain.com/privkey.pem
Deploying certificate
Successfully deployed certificate for your-domain.com to /etc/nginx/sites-enabled/my-app
Successfully deployed certificate for www.your-domain.com to /etc/nginx/sites-enabled/my-app
Congratulations! You have successfully enabled HTTPS on https://your-domain.com
```

*What just happened:* `certbot --nginx -d your-domain.com -d www.your-domain.com` asked Let's Encrypt for
a certificate covering both names. To prove you control the domain, Certbot briefly answered a challenge
over the domain from Step 1 (this is why DNS had to resolve first). On success it saved the certificate,
then - because of `--nginx` - *edited your site config for you*: added a `listen 443 ssl` block pointing
at the new certificate and a redirect from HTTP to HTTPS, and reloaded automatically. Visit
`https://your-domain.com` and you'll see the padlock.

**Renewal is automatic - but verify it.** Let's Encrypt certificates last 90 days; Certbot installs a
timer to renew them well before expiry. Confirm it's set up:

```console
deploy@web-prod-1:~$ sudo certbot renew --dry-run
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Processing /etc/letsencrypt/renewal/your-domain.com.conf
Simulating renewal of an existing certificate for your-domain.com and www.your-domain.com
Congratulations, all simulations of the renewal succeeded:
  /etc/letsencrypt/live/your-domain.com/fullchain.pem (success)
```

*What just happened:* `renew --dry-run` rehearsed the renewal without replacing anything. A clean run
means auto-renewal will work when the real expiry approaches, so you won't wake up to an
expired-certificate warning on your own site - the single best "trust but verify" step in this guide.

## The safety rules, in one place

You've built the happy path. These are the rules that keep it from quietly turning into a bad day. None
are optional for anything real.

- ⚠️ **Never expose your app port directly.** App stays on `127.0.0.1`; only nginx faces the internet.
  Re-read the Step 2 gotcha if this is fuzzy - it's the most common self-inflicted hole.
- ⚠️ **Never run your app as root.** Your systemd unit from
  [Phase 2](02-run-your-app-as-a-service.md) sets `User=deploy` for exactly this reason: if the app is
  compromised, the blast radius is one limited user, not the whole machine.
- ⚠️ **Set up backups before you need them.** A VPS is one machine; disks fail, fingers slip, `rm`
  happens. Snapshot the box on a schedule (most providers offer automated snapshots for a small fee) and,
  separately, back up the *data* that matters - your database, any user-uploaded files - somewhere off the
  box. The test of a backup is restoring it; an untested one is a hope, not a plan. Doing this on day one
  costs little; doing it after you lose data is impossible.
- 💡 **Keep the box patched.** The `apt update && apt upgrade` from Phase 1 isn't a one-time chore - run
  it regularly, and reboot when a kernel update needs it. An unpatched internet-facing box accumulates
  known holes over time.

## Recap

1. **DNS A record** points your domain at the box's IP; verify with **`dig +short`** before going
   further.
2. **nginx as a reverse proxy** sits public on **80/443** and forwards to your app on **`127.0.0.1:3000`**
   - test the config with **`nginx -t`**, apply with **`systemctl reload nginx`**.
3. **Certbot + Let's Encrypt** gets a free certificate, edits nginx to serve **HTTPS**, and auto-renews
   - confirm with **`certbot renew --dry-run`**.
4. The safety rules: **app port never exposed**, **app never runs as root**, **backups set up before you
   need them**, **box kept patched**.

That's the whole arc - from an empty rented box to your app live at a real `https://` URL, running as a
service that heals itself, behind a proxy that keeps it safe. Zero to live.

> Where to go next: when one box isn't enough - multiple app instances, load balancing across them,
> zero-downtime deploys - pick up [Load Balancers and nginx](/guides/load-balancers-and-nginx), which
> builds directly on the reverse proxy you just stood up.
