Prepare a small, controlled environment
This procedure targets a Linux system with systemd, an administrator account, an already installed Node.js 24 LTS runtime, curl and Caddy’s packaged system service. Confirm installed versions and package paths first; Node’s release page identifies supported release lines. Installation and provider provisioning are separate tasks. Use a machine you control and keep a tested SSH session and recovery path available. The names first-api and /opt/first-api must be unused before creating this example.
For HTTPS you also need a domain you control, correct A/AAAA records and permission to expose web traffic. api.example.com below is a reserved example: replace it with your own hostname. This API deliberately contains no database, authentication or customer data. It demonstrates a repeatable process, not a complete product or a tested OffVPS deployment.
Verify the runtime and create one release
command -v node &&
readlink -f "$(command -v node)" &&
node --version
The rest of the example assumes the verified shared executable is /usr/bin/node. If yours differs, replace that path in every check and in ExecStart. A runtime inside your login user’s private home is not automatically available to a system service. Create the service account and a root-owned release directory, stopping if an unexpected existing account or path is found.
sudo useradd --system --user-group --home-dir /opt/first-api \
--shell /usr/sbin/nologin first-api &&
sudo install -d -o root -g root -m 0755 /opt/first-api/releases/001
Using your editor with administrative access, save the following as /opt/first-api/releases/001/server.mjs, owned by root and readable by the service user. The release contains this file only; there are no package dependencies or secrets.
import http from 'node:http';
const port = Number(process.env.PORT || 3000);
if (!Number.isInteger(port) || port < 1024 || port > 65535) {
throw new Error('PORT must be an integer from 1024 to 65535');
}
const server = http.createServer((req, res) => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
if (req.method !== 'GET') {
res.writeHead(405, { Allow: 'GET' });
res.end(JSON.stringify({ error: 'method_not_allowed' }));
return;
}
if (req.url === '/healthz') {
res.writeHead(200);
res.end(JSON.stringify({ status: 'ok', release: '001' }));
} else if (req.url === '/api/message') {
res.writeHead(200);
res.end(JSON.stringify({ message: 'A small app, running clearly.' }));
} else {
res.writeHead(404);
res.end(JSON.stringify({ error: 'not_found' }));
}
});
server.requestTimeout = 10000;
server.headersTimeout = 10000;
server.keepAliveTimeout = 5000;
server.listen(port, '127.0.0.1');
process.on('SIGTERM', () => {
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 10000).unref();
});
The explicit loopback address keeps the API listener on the server itself. Caddy will be its public entry point. The Node HTTP API documents request handling, timeouts and server shutdown. Check the file as the account that will actually execute it:
sudo -u first-api /usr/bin/node --check /opt/first-api/releases/001/server.mjs
Give the process a service definition
Save /etc/systemd/system/first-api.service with this content:
[Unit]
Description=First API learning release
After=network.target
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=simple
User=first-api
Group=first-api
WorkingDirectory=/opt/first-api/releases/001
ExecStart=/usr/bin/node /opt/first-api/releases/001/server.mjs
Environment=NODE_ENV=production
Environment=PORT=3000
Restart=on-failure
RestartSec=5
TimeoutStopSec=15
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
[Install]
WantedBy=multi-user.target
The unit uses one dedicated user, an explicit executable and a versioned working directory. Automatic recovery is rate-limited; an intentional service stop does not trigger Restart=on-failure. See systemd.service. The filesystem restrictions fit this read-only API; an application that writes data needs deliberately scoped writable storage. Secrets do not belong in these environment lines. See systemd execution settings.
sudo systemd-analyze verify /etc/systemd/system/first-api.service &&
sudo systemctl daemon-reload &&
sudo systemctl start first-api.service &&
sudo systemctl status first-api.service --no-pager &&
curl --fail --show-error http://127.0.0.1:3000/healthz
The && guards stop the pasted sequence when a command fails. Resolve validation warnings before starting, and do not continue to the next block after a failed check. Unit verification can catch syntax and executable issues, but a successful check is not proof of a working application. The expected health response is {"status":"ok","release":"001"}. If it fails, inspect sudo journalctl -u first-api.service -n 50 --no-pager before repeatedly restarting.
Add the HTTPS route after the local check
Back up the existing Caddy configuration under an unused filename. Add this block to /etc/caddy/Caddyfile without replacing unrelated sites:
api.example.com {
reverse_proxy 127.0.0.1:3000
}
For the standard public-domain flow, the hostname must resolve to the server, ports 80/443 must reach Caddy, and Caddy’s certificate storage must remain writable and persistent. Check every published A/AAAA route. Leave SSH access intact and keep port 3000 private. These requirements come from Caddy automatic HTTPS; the upstream syntax is documented in reverse_proxy.
sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile &&
sudo systemctl reload caddy.service
Only reload after successful validation; caddy validate checks the adapted configuration. The packaged service workflow is described in Caddy’s service guide. From a separate client, request https://api.example.com/healthz using your real hostname. Expect a trusted TLS connection and the same release response, without bypassing certificate checks. Then verify /api/message returns its message and an unknown path returns 404.
Retain a known release and a safe stopping point
After both checks work, enable the API for future boots with sudo systemctl enable first-api.service. Record the runtime version, source file, unit and Caddy configuration. For the next release, create a new numbered directory, syntax-check it as the service user, update both unit paths, reload systemd and restart the API. Keep the prior directory until the new release is accepted.
To stop this example, use sudo systemctl stop first-api.service. Caddy will report an upstream failure while that route remains configured; remove only this route and validate/reload Caddy when retiring it. Roll back a failed code release by selecting the previous unit paths and repeating the checks. A later database migration needs its own recovery plan. Continue with the DNS-to-application request path or finding why an app stopped.
Documentation used
Primary references for this page. Check the documentation for the version installed in your own environment.