A lightweight, self-hosted document signing platform. Upload a PDF, add signers, share unique links — no signer account required. Signatures are drawn or typed in the browser, placed on the document, and merged into a final PDF using pdf-lib.
| Sign in | Dashboard |
|---|---|
![]() |
![]() |
Signing page — draw or type your signature, then click to place it on the document
- Register / log in with email and password (Supabase Auth)
- Upload PDFs up to 50 MB via drag-and-drop or file picker
- Add multiple signers — name, email, and optional signing order
- Ordered signing — enforce a sequence so signer 2 cannot sign until signer 1 completes
- Share signing links — each signer gets a unique UUID token URL; no signer account needed
- Dashboard — lists all documents with live status badges (Draft → Sent → In Progress → Completed)
- Document detail page — per-signer status, signed/opened timestamps, copy link button
- Download completed PDF — signed URL to the final merged document
- Delete documents — removes database records and both storage files
- No login required — signers open their unique link directly
- Draw or type signature — freehand canvas drawing or styled italic text
- Place anywhere — click any page to stamp the signature; drag to reposition before submitting
- Multi-page support — scroll through all pages and place signatures on any page
- Ordered signing guard — signer sees a "waiting for prior signer" screen if they arrive out of order
- Already-signed guard — duplicate submissions are rejected with a clear message
- When all signers have submitted, the backend automatically embeds all signatures into the original PDF using pdf-lib and stores the final document in Supabase Storage
- Signature coordinates are stored as page fractions (0.0–1.0), making placement resolution-independent across any screen size
| Layer | Technology |
|---|---|
| Frontend framework | Next.js 14 (App Router, TypeScript) |
| Backend framework | Express 4 (TypeScript) |
| Auth | Supabase Auth (email/password) |
| Database | Supabase (PostgreSQL) |
| File storage | Supabase Storage |
| PDF rendering (browser) | react-pdf v10 + pdfjs-dist v5 |
| PDF generation (server) | pdf-lib v1.17 |
| Signature canvas | react-signature-canvas |
| Styling | Tailwind CSS v3 |
| Containers | Docker multi-stage builds + Docker Compose |
| Monorepo | npm workspaces |
| Frontend hosting | Vercel |
| Backend hosting | Azure Container Apps |
| Container registry | Azure Container Registry |
| CI/CD | GitHub Actions |
simplesign/
├── apps/
│ ├── api/ # Express + TypeScript backend (port 4000)
│ │ └── src/
│ │ ├── config/env.ts # Zod-validated environment variables
│ │ ├── lib/supabase.ts # Anon client (JWT validation) + admin client (DB/storage)
│ │ ├── middleware/
│ │ │ ├── auth.ts # JWT → supabase.auth.getUser()
│ │ │ └── errorHandler.ts
│ │ ├── routes/ # Express routers
│ │ ├── controllers/ # Request/response handlers
│ │ ├── services/
│ │ │ ├── document.service.ts
│ │ │ ├── signing.service.ts # Session validation, ordered signing, completion detection
│ │ │ └── pdf.service.ts # pdf-lib merge + Storage upload
│ │ └── types/index.ts
│ │
│ └── web/ # Next.js 14 frontend (port 3000)
│ └── src/
│ ├── app/
│ │ ├── (auth)/ # Login + register pages
│ │ ├── dashboard/ # Document list, upload wizard, document detail
│ │ └── sign/[token]/ # Public signing page (no auth)
│ ├── lib/
│ │ ├── api.ts # Typed fetch wrapper → backend
│ │ ├── auth.ts # getAccessToken() helper
│ │ └── supabase.ts # Browser Supabase client (auth only)
│ └── hooks/useAuth.ts
│
├── supabase/schema.sql # Full database schema — run once in Supabase SQL Editor
├── docker-compose.yml
└── package.json # Root npm workspaces + dev scripts
All authenticated routes require Authorization: Bearer <supabase-jwt>.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/health |
None | Liveness probe — returns { status: "ok" } |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/documents |
JWT | List documents owned by the caller |
| POST | /api/documents |
JWT | Upload a PDF (multipart/form-data, field: file) |
| GET | /api/documents/:id |
JWT | Get document details + signers |
| DELETE | /api/documents/:id |
JWT | Delete document, signers, and storage files |
| POST | /api/documents/:id/signers |
JWT | Add signers { signers: [{ name, email, sign_order }] } |
| POST | /api/documents/:id/send |
JWT | Set status → sent, activates signing links |
| GET | /api/documents/:id/download |
JWT | Returns a 1-hour signed URL for the completed PDF |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/sign/:token |
None | Validate token; returns session, signer info, and a signed PDF URL |
| POST | /api/sign/:token |
None | Submit signature placements { placements: [...] } |
Placement object:
{
"page_number": 1,
"x": 0.1,
"y": 0.75,
"width": 0.25,
"height": 0.06,
"signature_data_url": "data:image/png;base64,..."
}All coordinates are fractions of the page dimensions (0.0–1.0).
documents
| Column | Type | Notes |
|---|---|---|
id |
UUID | Primary key |
owner_id |
UUID | References auth.users |
title |
TEXT | Filename shown in dashboard |
status |
enum | draft → sent → in_progress → completed |
storage_path |
TEXT | Path in documents storage bucket |
final_path |
TEXT | Path in completed-documents bucket (set on completion) |
page_count |
INTEGER | Page count of the original PDF |
signers
| Column | Type | Notes |
|---|---|---|
id |
UUID | Primary key |
document_id |
UUID | References documents |
name / email |
TEXT | Signer identity |
sign_order |
INTEGER | 1 = unordered; higher values enforce sequence |
status |
enum | pending → opened → signed |
token |
UUID | Unique signing link token |
signed_at / opened_at |
TIMESTAMPTZ | Audit timestamps |
signature_placements
| Column | Type | Notes |
|---|---|---|
signer_id / document_id |
UUID | Foreign keys |
page_number |
INTEGER | 1-indexed page |
x, y, width, height |
NUMERIC | Fractional page coordinates |
signature_data_url |
TEXT | Base64 PNG data URI |
| Bucket | Path pattern | Access |
|---|---|---|
documents |
{owner_id}/{doc_id}/original.pdf |
Private — backend generates signed URLs |
completed-documents |
{owner_id}/{doc_id}/final.pdf |
Private — backend generates signed URLs |
- Node.js 20+
- A Supabase project
In your Supabase dashboard → SQL Editor, paste and run the contents of supabase/schema.sql.
apps/api/.env
PORT=4000
NODE_ENV=development
SUPABASE_URL=https://<project-ref>.supabase.co
SUPABASE_ANON_KEY=eyJ...
SUPABASE_SERVICE_ROLE_KEY=eyJ...
FRONTEND_URL=http://localhost:3000apps/web/.env.local
NEXT_PUBLIC_SUPABASE_URL=https://<project-ref>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
NEXT_PUBLIC_API_URL=http://localhost:4000npm installnpm run dev- Frontend: http://localhost:3000
- Backend: http://localhost:4000
- Docker Desktop (or Docker Engine + Compose)
- A
.envfile at the repo root with the twoNEXT_PUBLIC_*variables (used as Docker build args):
NEXT_PUBLIC_SUPABASE_URL=https://<project-ref>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...docker compose up --build- Frontend: http://localhost:3000
- API: http://localhost:4000
The API container waits for the health check to pass before the web container starts.
- Both containers are built from the monorepo root as the Docker context so they share the root
package.jsonandpackage-lock.json - The web image uses Next.js standalone output with
outputFileTracingRootpointing to the monorepo root, which ensuresnextitself is bundled into the standalone directory - The PDF.js worker (
pdf.worker.min.mjs) is copied fromnode_modulesintoapps/web/public/during the Docker build and served as a static file
┌─────────────────────────────────────────────────────────┐
│ User's browser │
│ │ │
│ ▼ │
│ Vercel (Frontend) Azure Container Apps (API) │
│ docu-sign-simple-web ───► simplesign-api │
│ .vercel.app .australiaeast │
│ │ │ │
│ └──── Supabase Auth ───────┘ │
│ │ │
│ Supabase (DB + Storage) │
└─────────────────────────────────────────────────────────┘
Backend (Azure) — triggered on every push to main:
- GitHub Actions builds the Docker image from the monorepo root
- Pushes the image to Azure Container Registry (
simplesignacr.azurecr.io) - Updates the Container App with the new image and env vars
Frontend (Vercel) — triggered automatically by Vercel's GitHub integration on every push to main. No GitHub Actions step required.
Workflow file: .github/workflows/deploy.yml
- Create a project at supabase.com
- Run
supabase/schema.sqlin the SQL Editor - Note your project URL, anon key, and service role key
Install the Azure CLI (brew install azure-cli), then:
# Log in
az login
az extension add --name containerapp --upgrade
az provider register --namespace Microsoft.App
az provider register --namespace Microsoft.ContainerRegistry
# Create resources
az group create --name simplesign-rg --location australiaeast
az acr create \
--name simplesignacr \
--resource-group simplesign-rg \
--sku Basic \
--admin-enabled true
az containerapp env create \
--name simplesign-env \
--resource-group simplesign-rg \
--location australiaeast
az containerapp create \
--name simplesign-api \
--resource-group simplesign-rg \
--environment simplesign-env \
--image mcr.microsoft.com/azuredocs/containerapps-helloworld:latest \
--target-port 4000 \
--ingress external \
--min-replicas 1 \
--max-replicas 3
# Store Supabase credentials as Container App secrets
az containerapp secret set \
--name simplesign-api \
--resource-group simplesign-rg \
--secrets \
supabase-url="https://<ref>.supabase.co" \
supabase-anon-key="<anon-key>" \
supabase-service-role-key="<service-role-key>"Go to repo → Settings → Secrets and variables → Actions and add:
| Secret | How to get it |
|---|---|
AZURE_CREDENTIALS |
az ad sp create-for-rbac --name simplesign-github --role contributor --scopes /subscriptions/<id>/resourceGroups/simplesign-rg --sdk-auth |
ACR_LOGIN_SERVER |
az acr show --name simplesignacr --query loginServer -o tsv |
ACR_USERNAME |
az acr credential show --name simplesignacr --query username -o tsv |
ACR_PASSWORD |
az acr credential show --name simplesignacr --query passwords[0].value -o tsv |
ACR_NAME |
simplesignacr |
FRONTEND_URL |
Your Vercel production URL, e.g. https://your-app.vercel.app |
- Go to vercel.com → New Project → Import from GitHub
- In Project Settings → General → Root Directory, set to
apps/web - Add these environment variables in the Vercel project settings:
| Variable | Value |
|---|---|
NEXT_PUBLIC_SUPABASE_URL |
https://<ref>.supabase.co |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
your anon key |
NEXT_PUBLIC_API_URL |
your Azure Container App FQDN (e.g. https://simplesign-api.xxx.australiaeast.azurecontainerapps.io) |
- Deploy — Vercel auto-deploys on every push to
mainfrom this point forward
Push to main. GitHub Actions deploys the API to Azure (~3–5 min). Vercel deploys the frontend automatically.
git push origin mainVerify the API is live:
curl https://simplesign-api.purplesmoke-273680ee.australiaeast.azurecontainerapps.io/api/health
# → {"status":"ok","timestamp":"..."}Backend — set as Container App env vars via GitHub Actions
| Variable | Description |
|---|---|
PORT |
4000 |
NODE_ENV |
production |
SUPABASE_URL |
Supabase project URL |
SUPABASE_ANON_KEY |
Supabase anon key (JWT validation only) |
SUPABASE_SERVICE_ROLE_KEY |
Supabase service role key (DB + storage — never exposed to browser) |
FRONTEND_URL |
Vercel production URL — used for CORS. Accepts comma-separated values and *.domain wildcards |
Frontend — set in Vercel project settings
| Variable | Description |
|---|---|
NEXT_PUBLIC_SUPABASE_URL |
Supabase project URL |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
Supabase anon key (browser-safe) |
NEXT_PUBLIC_API_URL |
Full URL of the Azure Container App API |
- The
SUPABASE_SERVICE_ROLE_KEYis only ever present in the API container's environment — it is never sent to the browser - RLS is enabled on all tables; the backend bypasses it using the service role key and enforces ownership in the service layer
- JWT validation on authenticated routes uses
supabase.auth.getUser(token)— tokens are verified server-side, not just decoded - Signing links are single-use UUIDs — already-signed tokens are rejected with HTTP 409


