How this blog is built

The build sheet for this site: a hardened Debian server, EmDash in a gVisor container, nginx, TLS and CrowdSec.

This blog runs on a small Linode: EmDash on Astro and Node, SQLite for content, Docker with gVisor around the app, nginx in front, a Let's Encrypt certificate and CrowdSec watching the logs. This post is the build sheet for the exact server you are reading it on, in the order it was done.

If you only want the short version: one 2 GB Debian 13 server, key-only SSH, a cloud firewall, a read-only container with no capabilities under gVisor, nginx with modern TLS and a handful of headers, CrowdSec blocking at the firewall, and a nightly SQLite backup.

The server

A Linode 2 GB (Shared CPU, 1 vCPU) running Debian 13, in whichever region is closest to you. 1 GB is enough to serve EmDash but not to build it: npm and Astro need more than that during the image build, so either start at 2 GB or add swap.

linode-cli linodes create \
  --label myblog --region <region> --type g6-standard-1 \
  --image linode/debian13 \
  --authorized_keys "$(cat ~/.ssh/id_ed25519.pub)" \
  --root_pass "$(openssl rand -base64 32)"

The root password is random and thrown away. Nothing logs in with a password.

Before anything else, attach a Linode Cloud Firewall with an inbound policy of drop, and three accept rules: TCP 22 from your own address only, TCP 80 and 443 from anywhere, and ICMP. That keeps SSH off the internet entirely, before the server has done a single update.

DNS

Two records in the zone, A and AAAA, pointing at the server. Certbot needs them in place before it can issue the certificate.

Base hardening

Log in once as root, create your own user (youruser below), then lock root out:

adduser --disabled-password --comment "" youruser
usermod -aG sudo youruser
install -d -m 700 -o youruser -g youruser /home/youruser/.ssh
cp /root/.ssh/authorized_keys /home/youruser/.ssh/
chown youruser:youruser /home/youruser/.ssh/authorized_keys

SSH settings go in a drop-in file rather than the main config, so package upgrades never conflict with them. /etc/ssh/sshd_config.d/10-hardening.conf:

PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
AuthenticationMethods publickey
AllowUsers youruser
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
MaxAuthTries 3
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2

Check it with sshd -t, then systemctl reload ssh, and confirm a second session still gets in before you close the first.

Then the rest of the basics:

  • Updates: apt full-upgrade, then unattended-upgrades for security updates. Automatic reboots stay off, and needrestart tells you when one is due.
  • Swap: a 2 GB swap file with vm.swappiness=10, as headroom for image builds.
  • Kernel settings in /etc/sysctl.d/90-hardening.conf: kernel.kptr_restrict=2, kernel.dmesg_restrict=1, kernel.yama.ptrace_scope=1, reverse path filtering on, ICMP redirects and source routing off, SYN cookies on.
  • journald capped at 500 MB with SystemMaxUse=500M.
  • Hostname and timezone set, with the hostname in /etc/hosts so sudo does not complain.

Docker and gVisor

Docker comes from Docker's own apt repository, not Debian's. gVisor's runsc comes from gVisor's apt repository. /etc/docker/daemon.json registers the runtime, caps container logs, and makes no-new-privileges the default:

{
  "log-driver": "json-file",
  "log-opts": { "max-size": "10m", "max-file": "3" },
  "live-restore": true,
  "no-new-privileges": true,
  "runtimes": { "runsc": { "path": "/usr/bin/runsc" } }
}

Why gVisor: EmDash runs sandboxed plugins in workerd, and workerd's own README says it is not a hardened sandbox on its own. gVisor gives the whole container its own application kernel, so code inside it never talks to the host kernel directly.

The site

The site is EmDash's blog template, created with Node 24:

npm create emdash@latest blog -- --template blog --platform node --pm npm
cd blog
npm install @emdash-cms/sandbox-workerd workerd

The scaffolder writes an EMDASH_ENCRYPTION_KEY into .env. That key encrypts plugin secrets and is not part of any database backup, so copy it somewhere safe, such as a password manager, and chmod 600 .env.

The look is all in src/styles/theme.css, which overrides the template's design tokens: blue-black ink on white, slate greys, one cobalt accent, and a navy dark mode. Headings are Newsreader, text is IBM Plex Sans and code is IBM Plex Mono, set in astro.config.mjs. Astro downloads the fonts at build time and serves them from this site, so no page asks Google for anything.

The parts of astro.config.mjs that matter for running behind nginx:

export default defineConfig({
	site: "https://blog.example.com",
	output: "server",
	adapter: node({ mode: "standalone" }),
	session: {
		driver: sessionDrivers.fsLite({ base: "/app/data/sessions" }),
	},
	security: {
		allowedDomains: [
			{ hostname: "blog.example.com", protocol: "https" },
			{ hostname: "blog.example.com", protocol: "http" },
		],
	},
	integrations: [
		react(),
		emdash({
			database: sqlite({ url: "file:/app/data/emdash.db" }),
			storage: local({
				directory: "/app/data/uploads",
				baseUrl: "/_emdash/api/media/file",
			}),
			trustedProxyHeaders: ["x-real-ip"],
			sandboxRunner: "@emdash-cms/sandbox-workerd/sandbox",
		}),
	],
});

session moves sign-in sessions onto the data volume. Astro's Node adapter keeps them under node_modules/.astro by default, which is inside the read-only image, and sign-in fails with a server error right after the passkey is accepted. That one was found the hard way on this server.

allowedDomains tells Astro which forwarded host names to trust, and trustedProxyHeaders lets EmDash see the real client address for its sign-in rate limits. That second one is only safe because nginx is the only way in. The EmDash configuration reference covers each option.

The container

Dockerfile, a two-stage build on Debian slim. workerd is linked against glibc, so Alpine will not do:

FROM node:24-bookworm-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

FROM node:24-bookworm-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
RUN mkdir -p /app/data && chown node:node /app/data
USER node
ENV HOST=0.0.0.0 PORT=4321
EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"]

compose.yaml:

services:
  emdash:
    build: .
    restart: unless-stopped
    ports:
      - "127.0.0.1:4321:4321"
    networks:
      - emdash
    environment:
      EMDASH_SITE_URL: "https://blog.example.com"
      EMDASH_ENCRYPTION_KEY: "${EMDASH_ENCRYPTION_KEY:?set it in .env}"
    volumes:
      - emdash-data:/app/data
      - ./resolv.conf:/etc/resolv.conf:ro
    read_only: true
    tmpfs:
      - /tmp:size=64m
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    pids_limit: 512
    mem_limit: 768m
    runtime: runsc

networks:
  emdash: {}

volumes:
  emdash-data:

What each part does:

  • The port is published on the loopback address only, so the app is reachable from nginx on this host and nowhere else.
  • read_only with a small tmpfs on /tmp: the image cannot be changed at runtime, and the only writable places are /tmp (workerd's config and socket) and the data volume.
  • cap_drop: ALL and no-new-privileges: no Linux capabilities, and no way to gain them.
  • pids_limit and mem_limit stop a runaway plugin from taking the server down with it.
  • runtime: runsc runs the lot under gVisor.

resolv.conf is there because gVisor cannot reach Docker's internal DNS server on a user-defined network, so every outbound lookup fails without it. Point it at resolvers you trust:

nameserver 1.1.1.1
nameserver 9.9.9.9
options ndots:0

Build, check that gVisor and workerd both work, then start it:

docker compose build
docker run --rm --runtime=runsc --entrypoint sh <image> \
  -c 'uname -r; ./node_modules/workerd/bin/workerd --version'
docker compose up -d
curl -sI http://127.0.0.1:4321/ | head -1

uname -r ends in gvisor when it is working.

nginx and TLS

nginx comes from Debian, which already turns off server_tokens and old TLS versions. Three pieces of configuration:

  • A catch-all on port 80 that returns 444 to any host name it does not know, and a catch-all on 443 with ssl_reject_handshake on, so scanners hitting the bare IP get nothing.
  • A port 80 server for the blog that answers ACME challenges from /var/www/letsencrypt and redirects everything else to HTTPS.
  • The HTTPS server, which proxies to the container.

The certificate comes from Certbot in webroot mode, so Certbot never edits the nginx config:

certbot certonly --webroot -w /var/www/letsencrypt -d blog.example.com \
  --deploy-hook "systemctl reload nginx"

The HTTPS server block:

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name blog.example.com;

    ssl_certificate     /etc/letsencrypt/live/blog.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/blog.example.com/privkey.pem;
    ssl_session_tickets off;

    # EmDash sends the first three itself; one copy each, from nginx.
    # Server-Timing would show internal timings to every visitor.
    proxy_hide_header X-Content-Type-Options;
    proxy_hide_header Referrer-Policy;
    proxy_hide_header Permissions-Policy;
    proxy_hide_header Server-Timing;
    add_header Strict-Transport-Security "max-age=63072000" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
    add_header Content-Security-Policy "frame-ancestors 'self'; base-uri 'self'; object-src 'none'" always;

    client_max_body_size 50m;

    location / {
        proxy_pass http://127.0.0.1:4321;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
    }
}

The cipher list follows Mozilla's intermediate profile. $connection_upgrade comes from a map on $http_upgrade in conf.d, as in the nginx WebSocket guide. X-Forwarded-For is set to the client address rather than appended to, so nothing a client sends in that header gets passed through.

CrowdSec

CrowdSec reads the nginx logs and the SSH journal, spots brute force and scanning, and bans the source address. Install the engine from CrowdSec's own repository (Debian's package is far behind), then the nftables firewall bouncer, which does the blocking:

curl -s https://install.crowdsec.net | sudo sh
sudo apt install crowdsec crowdsec-firewall-bouncer-nftables
sudo cscli collections list

On install it finds nginx and sshd by itself and enables the matching collections, along with the community blocklist. fail2ban would do the same job twice, so it is not installed.

Test that a ban actually blocks, from a machine outside the server:

sudo cscli decisions add --ip <your address> --duration 4m --reason test
# requests from that address now time out
sudo cscli decisions delete --ip <your address>

Note that -r in cscli decisions add means range, not reason. The reason flag is --reason.

Backups

A small script, run nightly by a systemd timer, takes a consistent copy of the database with SQLite's .backup command, checks it with PRAGMA integrity_check, tars the uploads folder, and keeps 14 days in /var/backups/blog. A copy on the same disk only protects against mistakes, not against losing the server, so turn on Linode Backups or copy the files somewhere else as well. And keep the encryption key from .env with them.

First sign-in

With nginx and the certificate in place, open https://blog.example.com/_emdash/admin/ and run the setup wizard. EmDash signs in with passkeys, which are bound to the address you register them on. That is why the wizard waits until the site is on its real HTTPS address.

What this does not cover

workerd on Node enforces wall-time limits for plugins but not CPU or memory limits, which is why the container carries its own mem_limit and pids_limit. gVisor narrows what a compromised plugin could reach, but the best protection is still installing only plugins you trust. Watch EmDash's sandbox documentation for changes there.

No comments yet