Hectoday
Docs
Web fundamentals with @hectoday/http

Deployment from First Principles

A guide for developers who have a working app on localhost and no idea how to get it on the internet. Every example uses @hectoday/http, but the ideas apply to any server that exports a fetch function.

What deployment actually means

Your app runs on your machine. Deployment means running it on someone else's machine that's connected to the internet 24/7. That's it. Everything else — containers, CI/CD, infrastructure as code — is tooling to make that process reliable and repeatable.

The simplest deployment in the world:

  1. Copy your code to a server
  2. Install dependencies
  3. Start the process
  4. Point a domain name at the server's IP address

Everything fancier is an optimization of these four steps.

What "bring your own server" means

Hectoday gives you a fetch function. How you run it is up to you:

// app.ts
import { setup, route } from "@hectoday/http";

export const app = setup({
  routes: [
    route.get("/health", {
      resolve: () => Response.json({ status: "ok" }),
    }),
  ],
});
// server.ts — this is the part that changes per platform
import { app } from "./app";

Deno.serve(app.fetch);

The app.ts file never changes. The server.ts file is one line that differs per runtime. Your deployment target determines which line you write.

Option 1: Deno Deploy

The simplest path. No containers, no build step, no configuration files.

Your server file:

// server.ts
import { app } from "./app.ts";

Deno.serve(app.fetch);

Deploy:

deno install -gArf jsr:@deno/deployctl
deployctl deploy --project=your-project-name server.ts

That's it. Deno Deploy runs your code on the edge (close to your users), scales automatically, and handles HTTPS. Free tier covers hobby projects.

When to use: Side projects, small APIs, anything where you want zero infrastructure management.

Option 2: Bun on Railway / Fly.io

Railway and Fly.io run containers. They detect your runtime and build automatically.

Your server file:

// server.ts
import { app } from "./app";

Bun.serve({
  fetch: app.fetch,
  port: Number(process.env.PORT) || 3000,
});

For Railway, push to GitHub and connect the repo. It detects Bun from your bun.lock file and deploys.

For Fly.io, create a fly.toml:

app = "your-app-name"
primary_region = "ams"

[http_service]
  internal_port = 3000
  force_https = true

[build]
  builder = "heroku/buildpacks:22"

Deploy:

fly launch
fly deploy

When to use: When you need a persistent server, background jobs, or more control than edge functions provide.

Option 3: Cloudflare Workers

Edge deployment with a different API. Your server file:

// server.ts
import { app } from "./app";

export default { fetch: app.fetch };

Create a wrangler.toml:

name = "your-app-name"
main = "server.ts"
compatibility_date = "2024-01-01"

Deploy:

npx wrangler deploy

When to use: When you need global edge deployment, tight integration with Cloudflare's ecosystem (KV, D1, R2), or your app is stateless and lightweight.

Option 4: Node.js on a VPS

A traditional server you control. DigitalOcean, Hetzner, Linode, AWS EC2. You SSH in, install things, run your app.

Your server file:

// server.ts
import { serve } from "srvx";
import { app } from "./app";

serve({
  fetch: app.fetch,
  port: Number(process.env.PORT) || 3000,
});

On the server:

git clone your-repo
cd your-repo
npm install
node server.ts

This works, but the process dies when you close the terminal. Use a process manager:

# Install pm2
npm install -g pm2

# Start your app
pm2 start server.ts --name "api"

# It survives terminal close, restarts on crash
pm2 save
pm2 startup

Put a reverse proxy in front for HTTPS:

# /etc/nginx/sites-available/api
server {
    listen 443 ssl;
    server_name api.yourapp.com;

    ssl_certificate /etc/letsencrypt/live/api.yourapp.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.yourapp.com/privkey.pem;

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

When to use: When you need full control, specific hardware, or you're running alongside other services (databases, background workers).

Environment variables

Never hardcode secrets, database URLs, or configuration that changes between environments.

// Don't do this
const db = connect("postgres://user:password@prod-server:5432/mydb");

// Do this
const db = connect(process.env.DATABASE_URL);

Common environment variables for an API:

PORT=3000
DATABASE_URL=postgres://user:pass@host:5432/db
REDIS_URL=redis://host:6379
JWT_SECRET=your-secret-key
NODE_ENV=production

Every deployment platform has a way to set these. Deno Deploy has a dashboard. Railway has a variables tab. Fly.io has fly secrets set. A VPS uses a .env file or system environment.

Access them in your app:

const port = Number(process.env.PORT) || 3000;
const jwtSecret = process.env.JWT_SECRET;

if (!jwtSecret) {
  throw new Error("JWT_SECRET is required");
}

Fail fast if a required variable is missing. Don't let the app start in a broken state.

Health checks

Every deployment platform needs to know if your app is alive. A health check endpoint is the simplest way:

route.get("/health", {
  resolve: () => Response.json({ status: "ok" }),
});

The platform pings /health periodically. If it gets a 200, the app is healthy. If it gets no response or a 500, the platform restarts it.

For deeper health checks, verify your dependencies:

route.get("/health", {
  resolve: async () => {
    try {
      await db.query("SELECT 1");
    } catch {
      return Response.json({ status: "unhealthy", reason: "database" }, { status: 503 });
    }

    return Response.json({ status: "ok" });
  },
});

Don't put auth on health check endpoints. The platform needs to reach them without a token.

Graceful shutdown

When your app receives a shutdown signal (deploy, restart, scale-down), it should finish in-progress requests before exiting. Without this, clients get connection resets mid-request.

// server.ts (Node.js example)
import { serve } from "srvx";
import { app } from "./app";

const server = serve({
  fetch: app.fetch,
  port: Number(process.env.PORT) || 3000,
});

process.on("SIGTERM", async () => {
  console.log("Shutting down...");
  await server.close();
  process.exit(0);
});

Deno and Bun handle this automatically with Deno.serve and Bun.serve. On Node.js with a VPS, you need to handle it yourself.

HTTPS

Never deploy without HTTPS.

On Deno Deploy, Cloudflare Workers, Railway, Fly.io — HTTPS is automatic. You don't configure anything.

On a VPS — use Let's Encrypt with Certbot:

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d api.yourapp.com

Certbot gets a free certificate, configures Nginx, and auto-renews every 90 days.

Or use Caddy instead of Nginx. Caddy handles HTTPS automatically:

# Caddyfile
api.yourapp.com {
    reverse_proxy localhost:3000
}

That's the entire config. Caddy gets the certificate, terminates TLS, and proxies to your app.

Domain names

Buy a domain. Point it at your deployment.

For edge platforms (Deno Deploy, Cloudflare Workers) — add a custom domain in the dashboard. They give you a CNAME record to add to your DNS.

For app platforms (Railway, Fly.io) — same thing. Dashboard gives you DNS records.

For a VPS — create an A record pointing your domain to the server's IP address:

Type: A
Name: api
Value: 123.45.67.89
TTL: 300

This makes api.yourapp.com resolve to your server's IP. The reverse proxy (Nginx or Caddy) handles the rest.

Logs

Your onError hook logs unexpected errors. Your onResponse hook logs requests. In production, those logs need to go somewhere you can search them.

On managed platforms — logs go to the platform's dashboard automatically. Railway, Fly.io, Deno Deploy, Cloudflare Workers all have log viewers.

On a VPS — logs go to stdout by default. pm2 captures them:

pm2 logs api

For structured logging at scale, use a service like Axiom, Datadog, or Grafana Cloud. Pipe your JSON logs to their collector.

The minimum: make sure you can see your logs somewhere. If your app breaks at 3am, you need to know what happened without SSHing into a server.

CI/CD

Continuous Integration / Continuous Deployment means: push code, tests run, app deploys. No manual steps.

A minimal GitHub Actions workflow:

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: denoland/setup-deno@v1
      - run: deno test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: denoland/setup-deno@v1
      - run: deno install -gArf jsr:@deno/deployctl
      - run: deployctl deploy --project=your-project server.ts
        env:
          DENO_DEPLOY_TOKEN: ${{ secrets.DENO_DEPLOY_TOKEN }}

Push to main. Tests run. If they pass, the app deploys. If they fail, nothing deploys.

This is the minimum viable CI/CD. It prevents you from deploying broken code. Add linting, type checking, and staging environments as you grow.

Choosing a platform

Platform Runtime Cost Best for
Deno Deploy Deno Free tier, then usage-based Edge APIs, simple deployments
Cloudflare Workers Workers runtime Free tier, then usage-based Edge APIs, Cloudflare ecosystem
Railway Any (Bun, Node) Usage-based, ~$5/mo minimum Full-stack apps, databases included
Fly.io Any (containerized) Free tier, then usage-based Multi-region, persistent servers
VPS (Hetzner, DO) Any $4-20/mo fixed Full control, multiple services

Start with the simplest option that fits your needs. You can always move later. Your app is a fetch function. It runs anywhere.

Deployment checklist

Before your first production deploy:

  • Environment variables are set (not hardcoded)
  • Health check endpoint exists at /health
  • onError hook logs errors and returns a generic 500
  • HTTPS is configured (or automatic on your platform)
  • CORS is configured for your frontend's origin
  • Domain name points to your deployment
  • You can see logs somewhere
  • Tests pass before deploy (CI or manual)

After deploying:

  • Hit /health and get 200
  • Hit a real endpoint from your frontend
  • Check logs for errors
  • Verify CORS works from the browser (not just curl)

Summary

Concept What it means
Deployment Running your app on a server connected to the internet
Edge Running close to users worldwide (Deno Deploy, Cloudflare Workers)
VPS A server you control (DigitalOcean, Hetzner)
Environment variables Configuration that changes per environment
Health check Endpoint the platform pings to verify your app is alive
Graceful shutdown Finishing in-progress requests before stopping
Reverse proxy Nginx/Caddy in front of your app for HTTPS and routing
CI/CD Automated test-and-deploy on every push

Your app is app.fetch. Your server file is one line. Everything else is infrastructure to make that one line run reliably on the internet.