How to Deploy Remix (React Router v7) on Ubuntu 24.04 VPS: Express Server, PM2, Nginx
Remix is the full-stack web framework that pushed React back toward the web platform: real HTML forms, loaders and actions on the server, nested routes that stream progressively, and no client-side waterfalls. In late 2024, the Remix team merged the project into React Router v7, which now ships the same framework-mode APIs under a unified name. Whether your codebase still says @remix-run/* or has moved to react-router, the deployment story is identical: a Node process that server-renders your routes, a static asset directory under /build/, and an edge — in our case, Nginx — to terminate TLS and cache bundles.
This tutorial walks through a production Remix deployment on a single Ubuntu 24.04 LTS VPS. We will start from a fresh server, install Node.js 20, scaffold a Remix app, swap remix-serve for a custom Express server, run it under PM2 in cluster mode, front it with Nginx, and lock down immutable caching for the fingerprinted /build/ directory. You will finish with a deployment you can update with git pull && pm2 reload remix and that can comfortably serve tens of thousands of requests per day on a small VPS.
Skip the setup? Provision an Ubuntu 24.04 VPS in 60 seconds. Launch a CloudCore Starter and follow this guide from Step 1.
Table of Contents
Why Self-Host Remix Instead of Fly.io or Netlify
Remix was born on Fly.io and, for a time, most production deployments ran there or on Netlify, Vercel, and Cloudflare Pages. Those platforms are excellent for getting to production fast. They are also opinionated, usage-metered, and happy to charge you for every function invocation, every GB of egress, and every minute of build time.
A self-hosted Remix deployment on a VPS is compelling in four ways:
- Flat cost, no surprises. A CloudCore Starter at EUR 7.99/month serves more traffic than most apps will ever see. Netlify charges by function invocation, build minutes, bandwidth, and concurrent builds; a single viral page can add hundreds of euros to the bill.
- Full control over the runtime. On a VPS you can install native modules (sharp, canvas, Prisma query engines), run long-lived websocket servers, co-locate Redis or Postgres on the same box, and pin an exact Node version. Serverless platforms sandbox all of that.
- No cold starts. A PM2-managed Node process stays hot. Every request hits warm V8. Serverless loaders on a cold container can add 300-1500 ms before your code even runs.
- Data locality. You choose the data center, you see the logs, and your customers' data never transits a third-party function platform. For GDPR-sensitive apps this is a meaningful simplification.
Cost Comparison: Self-Hosted Remix vs. Managed Platforms
| Scenario | Netlify / Vercel | Fly.io (256MB shared) | Self-Hosted on CloudCore Starter |
|---|---|---|---|
| Base cost (idle) | Free tier, then per-seat | ~$1.94/month | EUR 7.99/month |
| 1M requests / 100 GB egress | ~$20 bandwidth + $25 functions | ~$19 usage | Included |
| Cold starts | Yes (functions) | Small (but present) | None |
| Custom Node version | Limited | Yes | Yes (any) |
| Websockets, long-running jobs | Paid add-ons | Yes | Yes |
| Native modules (sharp, Prisma) | Sometimes broken | Yes | Yes |
Prerequisites
Before starting, you need:
- An Ubuntu 24.04 LTS VPS with at least 1 vCPU, 2 GB RAM, and 20 GB SSD. Remix builds are memory-hungry; below 2 GB the build step will OOM.
- SSH access as root or a sudo user.
- A domain name pointed at the VPS public IP (A/AAAA records).
- Basic Node.js familiarity — you should be comfortable running
npm installand reading stack traces.
Recommended plan: CloudCore Starter>
A CloudCore Starter at EUR 7.99/month gives you 2 vCPU, 4 GB RAM, 50 GB NVMe, and unmetered bandwidth — more than enough for a production Remix app with Postgres or Redis co-located on the same box.
If Node.js or Nginx are not yet on the server, follow our companion guides first:
Connect to the server:ssh root@your-server-ipStep 1: Update Ubuntu and Install Build Tools
Start with a clean package index and the toolchain Node native modules need at install time:
sudo apt update && sudo apt upgrade -y
sudo apt install -y build-essential git curl ca-certificates gnupgbuild-essential pulls in gcc, g++, and make, which some npm packages (sharp, bcrypt, better-sqlite3) need to compile native bindings. git handles code deploys. If the kernel was updated, reboot now:
sudo rebootStep 2: Install Node.js 20 LTS
Remix officially supports Node 20 and later. Use the NodeSource repository so you can upgrade in place later:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejsVerify both node and npm:
node --version
npm --versionExpected output:
v20.18.1
10.8.2Enable Corepack so Yarn or pnpm work if your project prefers them:
sudo corepack enableFor a deeper look at Node.js installation options including nvm, see our Node.js on Ubuntu guide.
Step 3: Create a Dedicated Deploy User
Running Node as root is a security liability. Create a deploy user that owns the application directory:
sudo adduser --disabled-password --gecos "" deploy
sudo usermod -aG sudo deploy
sudo mkdir -p /var/www/remix-app
sudo chown -R deploy:deploy /var/www/remix-appCopy your SSH key so you can log in as deploy:
sudo rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy/Switch to the new user for the rest of the guide:
sudo su - deploy
cd /var/www/remix-appStep 4: Scaffold a Remix App
You have two equivalent starting points. If you are starting fresh in 2026, use the React Router v7 template (Remix's official successor):
npx create-react-router@latest .If you are deploying an existing Remix v2 codebase, skip scaffolding and git clone your repo into /var/www/remix-app instead. The deployment mechanics below are identical.
Choose TypeScript and answer yes to installing dependencies. The scaffold creates roughly this layout:
/var/www/remix-app
app/
entry.server.tsx
entry.client.tsx
root.tsx
routes/
public/
package.json
react-router.config.ts (or remix.config.js on v2)
vite.config.tsInstall any extra production dependencies you know you will need now, so they land in package-lock.json:
npm install compression morganWe will use both in the Express server later.
Step 5: Build and Smoke-Test with remix-serve
Before wiring a custom server, confirm the stock remix-serve (or react-router-serve on v7) flow works. Build the app:
npm run buildExpected output (abbreviated):
vite v5.4.10 building for production...
✓ 128 modules transformed.
build/client/assets/root-BtZ1k9f2.js 45.21 kB │ gzip: 16.43 kB
build/client/assets/root-Cv7x8q2a.css 1.03 kB │ gzip: 0.52 kB
build/server/index.js 142.88 kB
✓ built in 3.21sStart the production server on port 3000:
npm run startFrom a second SSH session, hit it:
curl -I http://127.0.0.1:3000You should see HTTP/1.1 200 OK and a text/html content type. Stop the process with Ctrl+C — we will now replace it with a custom Express server so we can add middleware, health checks, and better observability.
Step 6: Add a Custom Express Server
A custom server lets you add compression, rate limiting, request logging, and health endpoints without a separate sidecar. Create server.js at the project root:
// server.js import express from "express"; import compression from "compression"; import morgan from "morgan"; import { createRequestHandler } from "@react-router/express"; // On Remix v2, replace the line above with: // import { createRequestHandler } from "@remix-run/express";const BUILD_DIR = "./build/server/index.js"; const build = await import(BUILD_DIR);
const app = express();
// Remove the X-Powered-By header Express adds by default app.disable("x-powered-by");
// Gzip / brotli negotiation for HTML + loader data app.use(compression());
// Everything in public/ is long-lived — fingerprinted by Vite app.use( "/build", express.static("build/client/build", { immutable: true, maxAge: "1y", }), );
// Non-fingerprinted static assets (favicon, robots.txt) — short TTL app.use(express.static("build/client", { maxAge: "1h" }));
// Access log in combined Apache format app.use(morgan("combined"));
// Health check for PM2 / uptime monitoring app.get("/healthz", (_req, res) => res.status(200).send("ok"));
// Hand off everything else to the Remix / React Router request handler app.all( "*", createRequestHandler({ build, mode: process.env.NODE_ENV, }), );
const port = process.env.PORT ?? 3000; app.listen(port, () => { console.log(Remix server listening on http://127.0.0.1:${port}); });
For v7, install the Express adapter:
npm install @react-router/expressFor v2 Remix projects, install @remix-run/express instead. Fastify users can swap Express for Fastify and @mcansh/remix-fastify — the contract is identical.
Update package.json scripts to call the new entrypoint:
{
"type": "module",
"scripts": {
"dev": "react-router dev",
"build": "react-router build",
"start": "NODE_ENV=production node server.js"
}
}Rebuild and start:
npm run build
npm run start
curl -I http://127.0.0.1:3000/healthzYou should now see HTTP/1.1 200 OK from the health endpoint and morgan logs scrolling in the terminal.
Step 7: Loaders, Actions, and Session Storage
This is not a Remix syntax tutorial, but two production patterns deserve attention because they interact with deployment.
Loaders stream from the server — keep them fast
Every route can export a loader that runs on the Node process before HTML is sent:
// app/routes/products.$slug.tsx import { json } from "@react-router/node"; import { db } from "~/db.server";
export async function loader({ params }) { const product = await db.product.findUnique({ where: { slug: params.slug }, }); if (!product) throw new Response("Not Found", { status: 404 }); return json({ product }, { headers: { "Cache-Control": "public, max-age=60, stale-while-revalidate=600", }, }); }
Because loaders block the response, any slow query shows up immediately as TTFB. Co-locate your database on the same VPS when possible — loopback latency of ~0.1 ms destroys cross-region latency of 40 ms+.
Actions handle mutations — use progressive enhancement
export async function action({ request }) {
const formData = await request.formData();
await db.product.update({ / ... / });
return redirect("/products");
}Remix submits the HTML form natively if JavaScript hasn't loaded yet, so your app works during the React hydration window.
Session storage — choose based on scale
Remix ships three session adapters. Pick based on your traffic profile:
// app/sessions.server.ts import { createCookieSessionStorage } from "@react-router/node";
export const { getSession, commitSession, destroySession } = createCookieSessionStorage({ cookie: { name: "__session", httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", secrets: [process.env.SESSION_SECRET!], maxAge: 60 60 24 * 30, // 30 days }, });
Options:
- Cookie sessions (shown above) — session lives in a signed cookie. No server storage needed. Caps at ~4 KB. Perfect for auth state (user id, roles).
- File sessions (
createFileSessionStorage) — sessions stored under/var/www/remix-app/sessions. Works with a single PM2 process, breaks under cluster mode because workers don't share FS locks cleanly. Avoid. - Redis / Memcached sessions — use
remix-utilsor a customcreateSessionStorage. Mandatory if you use cluster mode and store >4 KB per user.
SESSION_SECRET as an environment variable — never commit it:openssl rand -hex 64 > ~/.remix-session-secret
chmod 600 ~/.remix-session-secretWe will pass it through PM2's env in the next step.
Step 8: Run Under PM2 in Cluster Mode
remix-serve or node server.js run a single Node process. On a 2 vCPU VPS you are wasting half the server. PM2 cluster mode forks one worker per vCPU and load-balances connections across them automatically, using Node's built-in cluster module.
Install PM2 globally:
sudo npm install -g pm2Create an ecosystem file at the project root so PM2 configuration lives in git:
// ecosystem.config.cjs
module.exports = {
apps: [
{
name: "remix",
script: "./server.js",
instances: "max",
exec_mode: "cluster",
env: {
NODE_ENV: "production",
PORT: 3000,
SESSION_SECRET: process.env.SESSION_SECRET,
},
max_memory_restart: "512M",
merge_logs: true,
kill_timeout: 5000,
wait_ready: false,
autorestart: true,
},
],
};instances: "max" uses every vCPU. max_memory_restart reloads a worker that leaks past 512 MB — a safety net against slow memory growth. kill_timeout gives in-flight requests 5 seconds to drain on reload.
Start the app:
export SESSION_SECRET=$(cat ~/.remix-session-secret)
pm2 start ecosystem.config.cjs
pm2 saveExpected output:
[PM2] Starting /var/www/remix-app/ecosystem.config.cjs in cluster_mode (2 instances)
[PM2] Done.
┌─────┬───────┬─────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
│ id │ name │ namespace│ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │
├─────┼───────┼─────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
│ 0 │ remix │ default │ 1.0.0 │ cluster │ 12345 │ 0s │ 0 │ online │ 0% │ 72.0 MB │ deploy │ disabled │
│ 1 │ remix │ default │ 1.0.0 │ cluster │ 12346 │ 0s │ 0 │ online │ 0% │ 71.8 MB │ deploy │ disabled │
└─────┴───────┴─────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘Make PM2 resurrect after reboot:
pm2 startup systemd -u deploy --hp /home/deploy
Copy the sudo command PM2 prints and run it
pm2 saveUseful PM2 commands:
pm2 logs remix --lines 100 # tail logs
pm2 reload remix # zero-downtime reload (use this after deploys)
pm2 restart remix # hard restart (kills workers immediately)
pm2 stop remix
pm2 monit # real-time CPU/mem dashboard
pm2 describe remix # full process infoZero-downtime reloads matter. pm2 reload tells workers to finish in-flight requests, then spawns fresh ones. Users never see a 502.
Step 9: systemd Alternative
If you prefer not to install PM2, systemd can manage the Node process directly. This is a good choice when you run a single instance or want to rely on journald for logs.
Create /etc/systemd/system/remix.service:
sudo tee /etc/systemd/system/remix.service > /dev/null <<'EOF' [Unit] Description=Remix (React Router v7) application After=network.target[Service] Type=simple User=deploy Group=deploy WorkingDirectory=/var/www/remix-app Environment=NODE_ENV=production Environment=PORT=3000 EnvironmentFile=/etc/remix-app.env ExecStart=/usr/bin/node /var/www/remix-app/server.js Restart=on-failure RestartSec=5 StandardOutput=journal StandardError=journal SyslogIdentifier=remix
Hardening
NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict ProtectHome=read-only ReadWritePaths=/var/www/remix-app
[Install] WantedBy=multi-user.target EOF
Store secrets in /etc/remix-app.env:
sudo tee /etc/remix-app.env > /dev/null <<EOF
SESSION_SECRET=$(cat /home/deploy/.remix-session-secret)
EOF
sudo chmod 600 /etc/remix-app.envEnable and start the service:
sudo systemctl daemon-reload
sudo systemctl enable --now remix
sudo systemctl status remixsystemd gives you journalctl -u remix -f for streaming logs, automatic restart on crash, and resource limits via MemoryMax= / CPUQuota=. The downside versus PM2: no built-in cluster mode. You would spawn workers manually with Node's cluster API inside server.js, or run multiple systemd services on different ports behind Nginx's upstream block.
Step 10: Nginx Reverse Proxy with Immutable /build/ Caching
Nginx terminates TLS, compresses responses, and — critically — serves Remix's fingerprinted /build/ assets with aggressive caching so browsers and CDNs never ask for them twice.
Install Nginx if it isn't present (see our Nginx install guide for the fuller walk-through):
sudo apt install -y nginxCreate the site config:
sudo tee /etc/nginx/sites-available/remix-app > /dev/null <<'EOF'Upstream Node process(es) managed by PM2
upstream remix_upstream { server 127.0.0.1:3000; keepalive 32; }HTTP -> HTTPS redirect
server { listen 80; listen [::]:80; server_name example.com www.example.com; return 301 https://$host$request_uri; }HTTPS virtual host
server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name example.com www.example.com;# TLS certs (Certbot writes these in Step 11) ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers off;
# Security headers add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options SAMEORIGIN always; add_header Referrer-Policy strict-origin-when-cross-origin always;
# Allow larger form posts (file uploads via Remix actions) client_max_body_size 25m;
# Gzip HTML, JSON, JS, CSS (Node already gzips, but Nginx covers static) gzip on; gzip_types text/plain text/css application/javascript application/json image/svg+xml; gzip_min_length 1024; gzip_vary on;
# Fingerprinted Remix/React Router bundles — cache forever # Vite emits files like root-BtZ1k9f2.js, so the hash is in the filename location /build/ { alias /var/www/remix-app/build/client/build/; access_log off; expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; try_files $uri =404; }
# Unhashed static assets in public/ — shorter TTL location /assets/ { alias /var/www/remix-app/build/client/assets/; access_log off; expires 1h; add_header Cache-Control "public, max-age=3600"; try_files $uri =404; }
# Everything else -> Node location / { proxy_pass http://remix_upstream; proxy_http_version 1.1; 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; proxy_set_header Connection ""; proxy_read_timeout 60s; proxy_send_timeout 60s;
# Allow Remix to stream responses (Suspense boundaries, defer()) proxy_buffering off; proxy_cache off; } } EOF
Enable the site and reload:
sudo ln -sf /etc/nginx/sites-available/remix-app /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginxWhy proxy_buffering off? Remix (and React Router v7) can stream HTML with defer() loaders and Suspense boundaries. Nginx's default buffering would hold the entire response before flushing — breaking streaming. Turning it off restores the progressive render.
Why serve /build/ from Nginx directly instead of through Node? Two reasons. First, Nginx serves static files roughly 10x faster than Node's express.static. Second, it frees the Node workers to handle loaders and actions, which is where the real cost sits. With this config, your Node process almost never sees a request for a JS bundle.
Step 11: TLS with Certbot
Free Let's Encrypt certificates, auto-renewed:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.comCertbot edits the Nginx config in place, fills in the ssl_certificate paths, and installs a systemd timer that renews certs twice daily. Verify it:
sudo systemctl list-timers | grep certbot
sudo certbot renew --dry-runVisit https://example.com in a browser. You should see your Remix app over TLS 1.3 with the lock icon.
Step 12: Deploy Updates Safely
A clean update loop for subsequent deploys:
cd /var/www/remix-app
git pull
npm ci --omit=dev=false # install including devDeps for the build step
npm run build
pm2 reload remix # zero-downtime swapBecause Vite emits new content hashes for every change, old bundles stay cached in browsers while new ones are requested with fresh filenames. No cache invalidation needed.
If a deploy goes wrong, roll back:
git reset --hard HEAD~1
npm ci
npm run build
pm2 reload remixFor truly zero-downtime you can also build in a staging directory and swap with a symlink:
ln -sfn /var/www/remix-app-new /var/www/remix-app-current
pm2 reload remixThat pattern works well once your CI pipeline grows beyond a git pull.
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
502 Bad Gateway from Nginx | Node process crashed or not listening on 3000 | pm2 logs remix — look at the stack trace. Check sudo ss -ltnp \</td><td>grep 3000 to confirm binding. |
npm run build OOM killed | Node build exceeds VPS RAM | Add swap: sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile. Or upgrade to a larger plan. |
| Streaming responses stall | Nginx buffering on | Confirm proxy_buffering off; inside the location / block, then sudo systemctl reload nginx. |
| Old CSS/JS in browser after deploy | Content hash didn't change | Force a full rebuild: rm -rf build node_modules/.vite && npm run build. Check that filenames under build/client/build/ contain a hash. |
EADDRINUSE on port 3000 | Previous Node process didn't exit | pm2 delete remix && pm2 start ecosystem.config.cjs or sudo fuser -k 3000/tcp. |
| Sessions lost between requests | Cluster mode + file-backed sessions | Switch to cookie or Redis sessions (see Step 7). |
| Loader data cached stale in CDN | Cache-Control missing on loader responses | Explicitly set headers in the loader: return json(data, { headers: { "Cache-Control": "private, no-cache" } }); |
ENOENT: no such file or directory, open 'build/server/index.js' | PM2 started before npm run build | Run build first, then pm2 reload remix. Add a prestart npm script to enforce order. |
Viewing logs
pm2 logs remix --lines 200 # PM2 app logs
sudo tail -f /var/log/nginx/access.log /var/log/nginx/error.log
sudo journalctl -u remix -f # systemd altFAQ
Do I need a custom server, or is remix-serve enough?
remix-serve (or react-router-serve on v7) is production-ready and fine for most deployments. Swap in a custom Express/Fastify server when you need: custom middleware (rate limiting, request logging, helmet), websockets alongside Remix, shared API routes that bypass the Remix handler, or complex auth that runs before routes. If none of those apply, sticking with remix-serve saves code and complexity.
How many PM2 instances should I run?
One per vCPU, which is what instances: "max" does. On a 2 vCPU CloudCore Starter that is 2 workers. Going higher than vCPU count yields nothing: Node is single-threaded per worker, and extra workers just context-switch against each other. Going lower leaves CPU on the table. The only time to pin a specific number is when the app is memory-bound — each worker loads its own copy of the build, so a large app on a small RAM budget may need instances: 1.
Should I use Remix v2 or migrate to React Router v7?
New projects: React Router v7 framework mode. It is the canonical path forward, ships the same APIs Remix users know, and is where the team's attention is. Existing Remix v2 apps: there is no rush. The v7 migration is largely renames (@remix-run/* → react-router, @react-router/node, @react-router/express). Follow the official React Router upgrade guide when you have a quiet week.
How does this compare to deploying Next.js?
Both ship a Node process that server-renders React and a static bundle directory. Operationally the Ubuntu deployment story is nearly identical — same PM2, same Nginx, same TLS. The architectural differences: Next.js leans heavier on its own build-time and edge abstractions (ISR, middleware, RSC), while Remix/React Router stays closer to the web platform (forms, loaders, standard Fetch). Pick Remix when you want a thinner runtime and native form handling; pick Next.js when you want ISR, the App Router ecosystem, or Vercel-first features. See our Next.js on Ubuntu guide for the parallel walkthrough.
Can I run Remix behind Cloudflare?
Yes, and it is recommended. Point your domain at Cloudflare, set DNS to proxied, and keep the Nginx config above on origin. Cloudflare caches your /build/ assets at the edge (the immutable Cache-Control header is obeyed), adds DDoS protection, and terminates TLS a second time. Use "Full (strict)" SSL mode so Cloudflare still verifies your Let's Encrypt cert.
Where should I put my database?
For a small-to-medium Remix app, co-locate Postgres or SQLite on the same VPS. Loader queries then run over loopback with sub-millisecond latency, which is the single biggest TTFB win you can get. Upgrade to a separate DB host only when the app outgrows a shared 4 GB of RAM, at which point add a private network between app and DB VPS to avoid public-internet egress costs.
Next Steps
- Add a CDN — Point Cloudflare (free plan) at the domain. Remix's
immutablebundle caching becomes edge-cached automatically, cutting origin bandwidth by 80 %+. - Add monitoring — Install Uptime Kuma on the same VPS and monitor
/healthz. Pair with PM2'spm2-logrotatemodule to keep log files bounded. - Add a database — Postgres (managed via
postgresql) or SQLite (via better-sqlite3) co-located on the same box. Use Prisma or Drizzle in loaders/actions. - Add background jobs — BullMQ on Redis gives you queued work without a second server. Run the worker as a second PM2 process in the same
ecosystem.config.cjs. - Read the official docs — remix.run/docs and reactrouter.com cover every loader/action/meta/links nuance, including streaming with
defer(), optimistic UI withuseFetcher, and resource routes for non-HTML endpoints.
Ready to deploy? A CloudCore Starter at EUR 7.99/month gives you 2 vCPU, 4 GB RAM, and 50 GB NVMe — everything this tutorial assumes, provisioned in under 60 seconds. Ubuntu 24.04 LTS is the default image, so you can paste Step 1 directly into your first SSH session.