Deploy SvelteKit on AWS EC2 with adapter-node
Aug 26, 2026 SvelteKit
adapter-node turns a SvelteKit app into a Node process. Run node build and it listens on a port, which makes it suitable for a VPS. adapter-auto tries to detect a serverless host. adapter-static has no server, so it cannot receive the form POST used in the tutorial.
An Amazon EC2 t3a.small has 2 vCPUs and 2 GiB of RAM on an AMD EPYC chip. The T3 page lists the on-demand price in us-east-1 at about $0.0188 an hour. Check the price in your region before launching one. The instance can run a small shop, but 2 GiB is tight for a Vite production build. Build on your laptop, copy build/ and the package files, then install the production dependencies on EC2.
In this setup, nginx listens on 443 and proxies requests to Node on 127.0.0.1:3000. PM2 restarts the process if it fails, and Let’s Encrypt issues the certificate. Replace shop.example.com with your domain.
The examples use Svelte 5 runes and SvelteKit 2. If sv create gave you SvelteKit 3, $lib may be #lib, and ORIGIN moves to paths.origin in config. The SvelteKit 3 migration guide covers those renames.
The finished server has these files:
svelte.config.js
/var/www/shop/
build/
package.json
package-lock.json
node_modules/
.env
ecosystem.config.cjs
/etc/nginx/sites-available/shop Table of Contents
Step 1: adapter-node on your laptop
You can reuse the shop from the tutorial or routing. To start fresh, install Node 18 or newer and run the Svelte CLI:
npx sv create shop
cd shop
npm install Pick TypeScript. Skip the add-ons.
sv create ships adapter-auto. Swap it:
npx sv add sveltekit-adapter="adapter:node" The adapter add-on installs @sveltejs/adapter-node and writes svelte.config.js. To configure it by hand:
npm i -D @sveltejs/adapter-node import adapter from '@sveltejs/adapter-node';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter()
}
};
export default config; Keep the defaults, out: 'build' and precompress: true. nginx terminates TLS. Node serves the .gz and .br files written by the adapter.
Build and start it locally:
npm run build
HOST=127.0.0.1 ORIGIN=http://localhost:3000 node build Open http://localhost:3000. You should see the app rather than Vite’s page on port 5173. Press Ctrl+C when you are done.
Do not use vite preview for this test. It runs in Node, but it loads .env the way vite dev does and handles the adapter’s listen flags differently. PM2 runs ./build/index.js, the same process started by node build, so test the production command.
Step 2: launch a t3a.small
Open the AWS console and create an EC2 instance.
- AMI: Ubuntu 24.04 LTS, 64-bit x86. t3a is AMD, not Graviton. An
arm64image will not boot on it. - Instance type:
t3a.small - Key pair: create one and download the
.pem, then runchmod 400on it. AWS will not show you the private key again. - Storage: 20 GiB gp3. The 8 GiB default fills up once Node, nginx, and a few deploys sit on the disk.
- Auto-assign public IP: enable
- Security group, inbound:
- SSH, port 22, your IP only
- HTTP, port 80,
0.0.0.0/0 - HTTPS, port 443,
0.0.0.0/0
Do not open 3000. nginx talks to Node on loopback.
t3a is burstable. Its baseline is 20% of each vCPU, and the instance earns 24 CPU credits an hour. A quiet shop may stay below that baseline. A production build burns CPU credits and also puts pressure on the 2 GiB of RAM.
Allocate and associate an Elastic IP. Without one, stopping and starting the instance may change its public address while the A record still points at the old IP.
Point an A record for shop.example.com at that Elastic IP. Wait until dig shop.example.com returns it. certbot will fail until that is true.
SSH in. The Ubuntu AMI user is ubuntu:
ssh -i /path/to/shop.pem ubuntu@YOUR_ELASTIC_IP Step 3: Node 22, nginx, a deploy user
On the instance:
sudo apt update
sudo apt install -y nginx rsync Ubuntu’s nodejs package is often older than what you built with. Install Node.js 22 from NodeSource:
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
node -v The command should print v22. PM2 calls /usr/bin/node.
Create a user that owns the app and cannot log in:
sudo useradd --system --home /var/www/shop --shell /usr/sbin/nologin shop
sudo mkdir -p /var/www/shop
sudo chown shop:shop /var/www/shop Your ubuntu user needs to write there during deploys:
sudo usermod -aG shop ubuntu
sudo chmod 775 /var/www/shop Log out and back in so the group applies. groups should list shop.
Step 4: copy the build, not node_modules
On your laptop, from the project root, after npm run build:
rsync -avz --delete
./build ./package.json ./package-lock.json
ubuntu@YOUR_ELASTIC_IP:/var/www/shop/ rsync copies the build and both package files. --delete removes old files from build/ when they are missing from the new release. Do not copy node_modules. A Mac ARM better-sqlite3 binary will not load on Linux AMD64. The SQLite post explains native bindings in more detail.
On the instance:
sudo chown -R shop:shop /var/www/shop
cd /var/www/shop
sudo -u shop npm ci --omit=dev npm ci reads package-lock.json and installs only dependencies. adapter-node bundles the required devDependencies into build/; runtime packages stay in node_modules. You can skip npm ci if the app has no production dependencies, though apps that use Postgres or S3 usually have some.
If you use pnpm on the laptop, copy pnpm-lock.yaml instead and run pnpm install --prod on the instance. This post stays on npm because sv create does.
Create /var/www/shop/.env on the instance. node build does not load this file by itself, as explained in Environment variables. Node 20.6+ accepts --env-file:
sudo -u shop tee /var/www/shop/.env >/dev/null <<'EOF'
ORIGIN=https://shop.example.com
EOF
sudo chmod 640 /var/www/shop/.env
sudo chown shop:shop /var/www/shop/.env Keep database URLs and API tokens in this file and out of Git. SvelteKit inlines $env/static/private values at build time, so changing one requires a new build. Use $env/dynamic/private when the process should read a value at runtime. The environment variables post explains the difference in more detail.
Smoke-test as the shop user before PM2:
cd /var/www/shop
sudo -u shop HOST=127.0.0.1 PORT=3000 ORIGIN=https://shop.example.com
/usr/bin/node --env-file=.env build In another terminal on the instance, curl -sI http://127.0.0.1:3000 should return 200. Stop the Node process with Ctrl+C.
If curl hangs, HOST is wrong or another process has already bound port 3000. Run ss -ltnp | grep 3000 to see which process owns it.
Step 5: PM2
Install PM2 globally. The shop user can then call the same binary:
sudo npm i -g pm2
which pm2 which should return /usr/bin/pm2.
Create /var/www/shop/ecosystem.config.cjs. sv create writes "type": "module" in package.json, but this config uses module.exports. The .cjs suffix tells Node to treat it as CommonJS:
module.exports = {
apps: [
{
name: 'shop',
cwd: '/var/www/shop',
script: './build/index.js',
interpreter: '/usr/bin/node',
node_args: '--env-file=/var/www/shop/.env',
exec_mode: 'fork',
env: {
NODE_ENV: 'production',
HOST: '127.0.0.1',
PORT: '3000',
PROTOCOL_HEADER: 'x-forwarded-proto',
HOST_HEADER: 'x-forwarded-host',
ADDRESS_HEADER: 'X-Forwarded-For',
XFF_DEPTH: '1'
},
kill_timeout: 40000,
restart_delay: 5000
}
]
}; HOST=127.0.0.1 keeps Node off the public interface. Keep HOST and PORT in this env block. Node’s --env-file does not override variables already set by PM2, so the ecosystem values win. If you remove HOST from env and set HOST=0.0.0.0 in .env, the Node port faces the internet.
nginx terminates TLS, so SvelteKit needs PROTOCOL_HEADER and HOST_HEADER. Without them, it thinks the request is for http://127.0.0.1:3000, and form actions fail with Cross-site POST form submissions are forbidden. ORIGIN in .env supplies the public URL. Set all three. I once wasted an afternoon on that error because only one side was correct.
ADDRESS_HEADER plus XFF_DEPTH=1 makes event.getClientAddress() return the client, not 127.0.0.1. nginx is the one trusted hop. Do not raise XFF_DEPTH because a blog told you to read the left-most address. The adapter-node docs explain the spoof.
kill_timeout: 40000 gives the adapter more than its 30 second SHUTDOWN_TIMEOUT. PM2 first sends SIGINT. adapter-node handles it like SIGTERM, stops accepting new work, and finishes active requests. PM2 sends SIGKILL if the process is still alive after 40 seconds.
node_args passes --env-file, which tells Node to read .env. Keep secrets there rather than in an ecosystem config that may get copied elsewhere. ORIGIN remains in the file created in Step 4.
sudo chown shop:shop /var/www/shop/ecosystem.config.cjs
cd /var/www/shop
sudo -u shop -H pm2 start ecosystem.config.cjs
sudo -u shop -H pm2 status The status should be online. Follow the logs with:
sudo -u shop -H pm2 logs shop PM2 writes the files under /var/www/shop/.pm2/logs/. An empty pm2 status list under ubuntu does not mean the app is down. That command talks to a different daemon. Run it with sudo -u shop -H to inspect the shop daemon.
Install the boot hook and save the process list. pm2 startup prints a sudo command for this server. Copy and run it before pm2 save:
sudo -u shop -H pm2 startup
# paste and run the sudo env PATH=... line it prints
sudo -u shop -H pm2 save This writes /etc/systemd/system/pm2-shop.service. After a reboot, the unit starts the PM2 daemon as shop. Keep application variables in the ecosystem file or .env, not in this unit.
Do not set instances: 'max' on this box. Cluster mode would start one Node process per CPU, which means two processes sharing 2 GiB of RAM. One process in fork mode is enough for a small shop.
Step 6: nginx
Create /etc/nginx/sites-available/shop:
server {
listen 80;
listen [::]:80;
server_name shop.example.com;
client_max_body_size 1m;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 60s;
}
} client_max_body_size is nginx’s limit. adapter-node’s BODY_SIZE_LIMIT defaults to 512 KB. That is why a 1 MB image can work in vite dev and return 413 after deployment. The file upload post sets BODY_SIZE_LIMIT on the Node process. Raise both limits, because either one can reject the request.
No gzip on here. precompress: true wrote compressed assets. Double gzip is wasted CPU on a 2 vCPU burstable box.
sudo ln -s /etc/nginx/sites-available/shop /etc/nginx/sites-enabled/shop
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx Open http://shop.example.com. The app should load over HTTP. The next step adds HTTPS.
A 502 usually means Node is down. Check sudo -u shop -H pm2 status, then read the last 50 lines with sudo -u shop -H pm2 logs shop --lines 50.
Step 7: TLS
Install Certbot’s nginx plugin:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d shop.example.com Certbot edits the nginx site file to listen on 443 and redirect port 80 to HTTPS. A systemd timer handles renewals. Test it with sudo certbot renew --dry-run.
After certbot finishes, confirm .env still has ORIGIN=https://shop.example.com with the s. Restart shop if you changed it:
sudo -u shop -H pm2 restart shop Submit a form if the app has one. A cross-site POST error means that ORIGIN or the forwarded headers are wrong. View the source of a form page and inspect the action URL. It should start with https://shop.example.com.
SvelteKit 3 stores the public URL as paths.origin in config instead of the ORIGIN env var.
Step 8: a second deploy
On the laptop:
npm run build
rsync -avz --delete
./build ./package.json ./package-lock.json
ubuntu@YOUR_ELASTIC_IP:/var/www/shop/ On the instance:
sudo chown -R shop:shop /var/www/shop
cd /var/www/shop
sudo -u shop npm ci --omit=dev
sudo -u shop -H pm2 restart shop restart sends SIGINT, and adapter-node gives active requests up to 30 seconds to finish. It drops anything that takes longer. After editing the env block in ecosystem.config.cjs, use pm2 restart shop --update-env; otherwise PM2 keeps the old values.
A cookie session stored in a Map disappears on restart. The HttpOnly cookie post uses that Map. Before using it for real logins, move the sessions to SQLite or Postgres.
Optional: build on the instance anyway
I would keep builds off this instance. If you still want to build there, add swap before npm run build. Otherwise the OOM killer may stop Node, leaving only Killed and no Vite stack trace.
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 Many SvelteKit builds finish with 2 GiB of swap and 2 GiB of RAM, but they are slow and consume CPU credits. Building on a GitHub Actions runner and copying build/ with rsync avoids both costs on the EC2 instance.
Things that trip people up
Leaving adapter-auto in svelte.config.js produces a build/ folder for a serverless filesystem rather than a long-running Node process.
Do not copy node_modules from a laptop. Install the packages on the instance.
Running npm run build on the t3a.small without swap may trigger the OOM killer. Check with dmesg | grep -i kill.
Opening port 3000 in the security group, or setting HOST=0.0.0.0 without nginx, exposes the Node process to the internet without TLS.
.env does not load in production just because it worked with vite preview. Pass --env-file or put the values in the PM2 env block.
An empty pm2 status under ubuntu is expected because the daemon belongs to shop. Prefix PM2 commands with sudo -u shop -H.
For a form CSRF error, set ORIGIN to the HTTPS URL shown in the browser. Also set PROTOCOL_HEADER and HOST_HEADER because the request passes through nginx.
$env/static/private values are fixed by npm run build, so editing .env on the server does not change them. Use $env/dynamic/private for tokens that differ between machines. The environment variables post shows both modules.
An 8 GiB root volume can fill up with apt, node_modules, and an old build. Run df -h when a deployment fails with ENOSPC.
An arm64 Ubuntu image will not run on t3a. Pick x86_64 for this instance type. For ARM, use the Graviton-based t4g and rebuild native modules for that architecture.
Do not leave SSH open to 0.0.0.0/0. Restrict port 22 to your IP. If you change networks and lock yourself out, use the EC2 serial console or temporarily open port 22. Dropping another key into /tmp does nothing until you can SSH.
What to add next
For a file upload, set BODY_SIZE_LIMIT in the PM2 env block and use the same client_max_body_size in nginx. The t3a.small still has only one gp3 volume. S3 with presigned URLs stores the file in S3 instead.
SQLite can live at /var/www/shop/data because this process is long-lived. Back up the database file. A serverless host still cannot keep it.
This site runs on Vercel with adapter-vercel. The SvelteKit app stays the same; only the adapter changes. Docker on this instance is a later topic.