Dockerfile
Rama main · réplica de lectura del repositorio original.
# syntax=docker/dockerfile:1.7
#
# Tessera server image: the sync and API server (apps/server) serving the web app (apps/web).
# One container is the whole product. Owner: Agent 07 (Desktop & self-host).
#
# docker build -t tessera .
# docker run -d -p 8787:8787 -v tessera-data:/data tessera
#
# Multi-arch: docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/<owner>/tessera .
ARG NODE_VERSION=24.15.0
ARG ALPINE_VERSION=3.22
ARG PNPM_VERSION=11.9.0
# ---------------------------------------------------------------------------------------------
# 1. Dependencies: download by lockfile (cached until pnpm-lock.yaml changes), then install.
# ---------------------------------------------------------------------------------------------
FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION} AS deps
ARG PNPM_VERSION
ENV PNPM_HOME=/pnpm \
PATH=/pnpm:$PATH \
CI=true \
# No browsers for Playwright, no telemetry.
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \
npm_config_update_notifier=false
# Toolchain for native modules (better-sqlite3, argon2) when no prebuilt binary matches.
RUN apk add --no-cache python3 make g++ \
&& npm install --global --no-fund --no-audit pnpm@${PNPM_VERSION}
WORKDIR /repo
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm config set store-dir /pnpm/store \
&& pnpm fetch --frozen-lockfile
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm config set store-dir /pnpm/store \
&& pnpm install --frozen-lockfile --offline
# ---------------------------------------------------------------------------------------------
# 2. Build the web app and the server, then a production-only copy of the server.
# ---------------------------------------------------------------------------------------------
FROM deps AS build
RUN pnpm --filter @tessera/web build \
&& pnpm --filter @tessera/server build
# `pnpm deploy` writes the server with only its production dependencies (native modules
# included). Workspace packages are bundled into dist/ by tsdown, so they aren't needed at runtime.
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm config set store-dir /pnpm/store \
&& pnpm --filter @tessera/server deploy --prod --legacy /out/apps/server \
&& rm -rf /out/apps/server/src /out/apps/server/*.config.ts /out/apps/server/tsconfig.json \
&& mkdir -p /out/apps/web \
&& cp -r apps/web/dist /out/apps/web/dist \
&& find /out -name '*.map' -delete
# Slim the native modules: prebuilt binaries for other platforms, and the C/C++ sources and
# build intermediates of addons (a compiled addon keeps only build/Release/*.node). Then check
# that both native modules still load.
COPY <<'EOF' /tmp/prune.mjs
import { existsSync, readdirSync, rmSync } from 'node:fs';
import path from 'node:path';
const keepPrebuild = (name) => name.includes('linux') && name.includes(`-${process.arch}`);
const hasJs = (dir) =>
readdirSync(dir, { withFileTypes: true }).some((entry) =>
entry.isDirectory() ? hasJs(path.join(dir, entry.name)) : /\.[cm]?js$/.test(entry.name),
);
const remove = (target) => rmSync(target, { recursive: true, force: true });
function prune(dir) {
const entries = readdirSync(dir, { withFileTypes: true });
if (entries.some((entry) => entry.name === 'binding.gyp')) {
for (const sources of ['deps', 'src']) {
const target = path.join(dir, sources);
if (existsSync(target) && !hasJs(target)) remove(target);
}
const build = path.join(dir, 'build');
if (existsSync(build)) {
for (const name of readdirSync(build)) if (name !== 'Release') remove(path.join(build, name));
const release = path.join(build, 'Release');
if (existsSync(release))
for (const name of readdirSync(release)) if (!name.endsWith('.node')) remove(path.join(release, name));
}
}
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isSymbolicLink()) continue;
if (entry.isDirectory() && entry.name === 'prebuilds') {
for (const name of readdirSync(full)) if (!keepPrebuild(name)) remove(path.join(full, name));
} else if (entry.isDirectory()) {
prune(full);
} else if (/\.d\.[cm]?ts$/.test(entry.name)) {
remove(full);
}
}
}
prune(process.argv[2]);
EOF
RUN node /tmp/prune.mjs /out \
&& cd /out/apps/server \
&& node -e "require('better-sqlite3')(':memory:').prepare('select 1').get(); require('argon2')"
# ---------------------------------------------------------------------------------------------
# 3. Runtime: plain Alpine with only the node binary (no npm, corepack or yarn: 22 MB less),
# a non-root user, and the data in /data.
# ---------------------------------------------------------------------------------------------
FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION} AS node
FROM alpine:${ALPINE_VERSION} AS runtime
LABEL org.opencontainers.image.title="Tessera" \
org.opencontainers.image.description="Local-first knowledge app: sync server and web app" \
org.opencontainers.image.source="https://github.com/femboypuppy/Tessera-Notes" \
org.opencontainers.image.licenses="MIT"
# libstdc++/libgcc: what the node binary links against. tini forwards signals (a graceful
# shutdown flushes the database) and reaps zombies.
RUN apk add --no-cache libstdc++ libgcc tini su-exec \
&& addgroup -g 1000 node \
&& adduser -u 1000 -G node -s /bin/sh -D node \
&& mkdir -p /data \
&& chown node:node /data
COPY --from=node /usr/local/bin/node /usr/local/bin/node
ENV NODE_ENV=production \
PORT=8787 \
DATA_DIR=/data \
WEB_DIR=/app/apps/web/dist
WORKDIR /app
# Same layout as the repository: apps/server/dist/../../web/dist is the web build.
COPY --from=build --chown=node:node /out/apps /app/apps
COPY --chmod=755 <<'EOF' /usr/local/bin/tessera-server
#!/bin/sh
# The server CLI: `tessera-server` starts it; subcommands (create-owner, backup, restore) are the
# server's (apps/server/README.md).
exec node /app/apps/server/dist/main.js "$@"
EOF
COPY --chmod=755 <<'EOF' /usr/local/bin/docker-entrypoint
#!/bin/sh
# By default the container starts as `node` and runs the command directly. Some platforms mount
# volumes owned by root (Fly.io, Railway, Render); started as root there, the entrypoint gives
# the data folder to `node` once, then drops privileges. The server never runs as root.
set -e
if [ "$(id -u)" = "0" ]; then
data="${DATA_DIR:-/data}"
mkdir -p "$data"
if [ "$(stat -c %u "$data")" != "1000" ]; then
chown -R node:node "$data"
fi
exec su-exec node "$@"
fi
exec "$@"
EOF
USER node
VOLUME ["/data"]
EXPOSE 8787
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||8787)+'/api/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
ENTRYPOINT ["/sbin/tini", "--", "docker-entrypoint"]
CMD ["tessera-server"]