Self-hosted Form Backend as a Service (FBaaS). One Docker stack, one API key, every form on every site you own.
Quick start · How it works · Architecture · API · S3 uploads · Docs
Submify gives you a private Formspree/Form-to-email replacement you run yourself: a Go (Gin) API, a Next.js dashboard, PostgreSQL for storage, optional external S3-compatible storage for file uploads, and Nginx as a single entrypoint. Point any website's contact form at it, log in to read submissions, export to XLSX/PDF, and optionally get a Telegram ping when something new comes in.
Submify is built and maintained by Nodedr Infotech Private Limited, and released under the AGPL-3.0 license — free to self-host, modify, and use commercially. If you run a modified version as a network service for others, you must share that modified source under the same license.
Repository: github.com/Raktim94/Submify
- You create an account and a project. Registering gives you an account-level
api_keyand a default project with its ownpk_live_...public key. - You embed that key in your website's form. No SDK needed — any HTML form or
fetch()call canPOSTJSON straight tohttps://your-host:2512/api/submitwith the key in thex-api-keyheader. - Submify validates and stores it. The Go API checks the key, applies rate limits, and writes the submission's JSON payload into PostgreSQL under that project — isolated from every other account and project in the same database.
- You read it from the dashboard. The Next.js app calls the same API over
/api/v1/...with a Bearer session token, lists submissions per project, and can export them as XLSX or PDF. - Optional add-ons fire on submit. If Telegram is configured, you get a chat notification. If a file was attached, the browser already uploaded it directly to your own S3-compatible bucket via a short-lived presigned URL — Submify only stores the resulting object key, never the file bytes.
Everything — Postgres, the API, the dashboard, and Nginx in front of both — runs as one Docker Compose stack you control. There's no third-party SaaS in the request path.
- One API key, every site. A single account
api_keyworks across all your projects; no per-form setup dance. - You own the data. Everything lives in your own PostgreSQL container — no third-party form service in the loop.
- Bring your own storage. AWS S3, Cloudflare R2, MinIO, Wasabi — anything S3-compatible works for file uploads.
- One command to run. A single Docker Compose stack behind Nginx; strong secrets are generated for you on first boot.
- JSON form submission API — one primary
api_keyper account, plus optional per-project keys - Admin dashboard with JWT (access + refresh token) login
- Projects CRUD, submission inbox, bulk delete
- Export submissions as XLSX or PDF
- Client portal — share a per-project, password-protected
/(slug)URL so a client can view and export their submissions only (no dashboard account) - Optional Telegram notification on new submission
- Optional presigned PUT uploads to any S3-compatible storage
Email notifications aren't built in — send mail from your own app after posting to Submify if you need that.
Requires Docker Engine and the Compose v2 plugin.
curl -fsSL https://raw.githubusercontent.com/Raktim94/Submify/main/install.sh | bashThat single command clones the repo into ./Submify, generates strong random secrets on first run, and brings up Postgres, the API, the dashboard, and Nginx with docker compose up --build -d. Re-run it any time to pull the latest version and redeploy.
Once the containers are up, open http://localhost:2512 and create your first account at /register.
Updating to the latest version:
cd ~/Submify
git pull
docker compose up -d --buildSee Operations: logs, backup, updates for the full version with build-cache pruning.
Prefer to see exactly what you're running first? Use the manual steps below instead.
git clone https://github.com/Raktim94/Submify.git
cd Submify
./scripts/compose-up.sh up --build -dWindows (PowerShell):
git clone https://github.com/Raktim94/Submify.git
cd Submify
.\scripts\Compose-Up.ps1 up --build -dNo .env is required to get started — compose-up.sh / Compose-Up.ps1 auto-create .env.auto with strong random POSTGRES_PASSWORD and JWT_SECRET values on first run. Copy .env.example to .env only when you want to override defaults (custom CORS origins, port, cookie settings, etc.).
docker compose ps # check container health
docker compose logs -f api # follow API logs- How it works
- Architecture
- Requirements
- Quick start
- URLs and ports
- Accessing via network IP
- Configuration and environment variables
- First-time access
- Optional: Cloudflare Tunnel
- API overview
- Connecting a client website (forms)
- Integrating with an AI coding assistant
- External S3 uploads (optional)
- Dashboard workflow
- Client portal (share view-only access)
- Limits and security defaults
- Security and vulnerability scanning
- Using GitHub Actions secrets
- Operations: logs, backup, updates
- Troubleshooting
- License
- Developer & ownership
┌────────────────────────┐
Browser ───▶ │ Nginx :2512 │
/ clients └────────────┬───────────┘
│
┌─────────────┴─────────────┐
▼ ▼
/api/* → Go API (Gin) :8080 /* → Next.js dashboard :3000
│
▼
PostgreSQL (single DB, JSONB-friendly)
│
▼ (optional, per project)
External S3-compatible storage (AWS S3 / R2 / MinIO / Wasabi)
- Nginx is the only published port (2512) and proxies
/api/*to the Go API and everything else to the Next.js app. - PostgreSQL stores all tenants in one database. Rows are scoped by
user_id/project_id; the API never lists or mutates another user's data. - Object storage is optional and external — connect any S3-compatible provider per project from the dashboard. No storage container ships with the stack.
The browser and external clients should use one origin for dashboard + API (e.g. https://forms.example.com:2512/api/v1/...), or configure CORS for separate sites — see Connecting a client website.
- Linux, macOS, or Windows host with admin access (sudo-capable user on Linux)
- Docker Engine and Docker Compose v2 plugin
- Inbound TCP 2512 open on your host firewall / security group (or whatever port you front it with)
- TLS termination (reverse proxy or tunnel) for production
Default Compose uses ./data/postgres next to the compose file, so the stack runs unmodified on Windows, macOS, Linux, and CasaOS-style installs.
Linux (recommended for servers):
- Install Docker Engine + Compose plugin (see the official docs).
- Verify:
docker --versionanddocker compose version. - Add your user to the
dockergroup so you don't needsudofor every command:sudo usermod -aG docker $USER, then re-login.
Windows / macOS: install Docker Desktop, make sure it's running, then verify the same two commands above.
Debian users — common pitfall: If you installed Docker from the Debian repositories (
apt install docker.io), the Compose v2 plugin is not included. Runningdocker compose versionwill returndocker: 'compose' is not a docker command. Follow the fix below before running the installer.
This happens when Docker was installed from Debian's package (docker.io) instead of Docker's official repository. The two package trees conflict — Debian ships its own docker-buildx that blocks Docker's official docker-buildx-plugin.
Step 1 — Add Docker's official repository:
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
| sudo tee /etc/apt/sources.list.d/docker.list
sudo apt updateStep 2 — Remove the conflicting Debian buildx package:
sudo apt remove docker-buildxStep 3 — Install the official Compose and Buildx plugins:
sudo apt install docker-buildx-plugin docker-compose-pluginIf apt install still reports broken packages, run:
sudo apt --fix-broken install
sudo apt install docker-buildx-plugin docker-compose-pluginStep 4 — Verify and re-run the installer:
docker buildx version
docker compose version
curl -fsSL https://raw.githubusercontent.com/Raktim94/Submify/main/install.sh | bashNginx is the only service that publishes a port in the default docker-compose.yml: 2512, bound to 127.0.0.1 by default.
| What | URL |
|---|---|
| Web dashboard | http://<your-server-ip>:2512 (e.g. http://localhost:2512) |
| API | same host, under /api/v1 |
| Health check | http://<your-server-ip>:2512/api/v1/system/health |
You don't open port 8080 on the host — that's only the API listening inside its container. Traffic flow: Browser → :2512 (nginx) → /api/* → api:8080 and → /* → web:3000.
Allow TCP 2512 from whatever networks should reach the UI/API (or 80/443 if you terminate TLS in front).
By default the stack binds only to 127.0.0.1, so it is reachable from the same machine only. To reach it from other devices on your network (or from the internet), do the following:
Step 1 — Bind to all interfaces (or a specific IP)
Add these two lines to your .env file (create it if it doesn't exist — copy .env.example as a starting point):
# Bind Nginx to all interfaces so other devices can reach port 2512.
# Replace 0.0.0.0 with a specific IP if you want to restrict to one interface.
SUBMIFY_BIND_IP=0.0.0.0
# Tell the API to allow requests coming from your server's IP.
# Replace 192.168.1.100 with your actual server IP (or domain).
# Keep localhost/127.0.0.1 entries so the dashboard still works locally.
ALLOWED_ORIGINS=http://localhost:2512,http://127.0.0.1:2512,http://192.168.1.100:2512Step 2 — Restart the stack
docker compose up -dStep 3 — Open your firewall
Make sure the host firewall allows inbound TCP on port 2512:
# UFW (Ubuntu / Debian)
sudo ufw allow 2512/tcp
# firewalld (Fedora / RHEL / CentOS)
sudo firewall-cmd --add-port=2512/tcp --permanent && sudo firewall-cmd --reload
# iptables
sudo iptables -I INPUT -p tcp --dport 2512 -j ACCEPTNow open http://<server-ip>:2512 from any device on the same network.
Production note: For internet-facing deployments, put Submify behind a reverse proxy (Nginx, Caddy, Traefik) or a Cloudflare Tunnel with TLS instead of exposing port 2512 directly. See Optional: Cloudflare Tunnel.
No .env is required — defaults live in docker-compose.yml, and ./scripts/compose-up.sh auto-generates strong secrets into .env.auto on first run. Copy .env.example to .env only to override values.
API container (see apps/api/internal/config/config.go):
| Variable | Default | Meaning |
|---|---|---|
PORT |
8080 |
HTTP port inside the API container |
DATABASE_URL |
Compose default to db |
PostgreSQL connection string |
JWT_SECRET |
random per-boot, or .env/.env.auto |
JWT HMAC secret (≥32 characters; always override in production) |
ALLOWED_ORIGINS |
http://localhost:2512,http://127.0.0.1:2512 |
CORS allowlist (comma-separated) |
UPLOAD_MAX_SIZE_BYTES |
26214400 (25 MiB) |
Max upload size for presign |
UPLOAD_ALLOWED_MIME |
image/png,image/jpeg,application/pdf,text/plain |
Allowed MIME types for presign |
PRESIGN_EXPIRY_MINUTES |
10 |
Presigned URL lifetime |
ACCESS_TOKEN_TTL_MINUTES |
30 |
Access token lifetime |
REFRESH_TOKEN_TTL_HOURS |
24 |
Refresh token lifetime |
POSTGRES_PASSWORD |
built-in default, or .env/.env.auto |
DB password; must match DATABASE_URL |
TRUSTED_PROXIES |
private RFC1918 + loopback | CIDRs allowed to set X-Forwarded-For |
RATE_LIMIT_SENSITIVE_PUBLIC_RPM |
25 |
Login / setup / refresh / logout per IP |
RATE_LIMIT_SUBMIT_IP_RPM |
90 |
Public submit per client IP |
RATE_LIMIT_SUBMIT_KEY_RPM |
180 |
Public submit per API key |
RATE_LIMIT_AUTH_USER_RPM |
600 |
Authenticated API per user id |
Web container:
| Variable | Typical value | Meaning |
|---|---|---|
NEXT_PUBLIC_API_BASE |
/api/v1 |
Browser-side API prefix |
NODEDR_SUBMIT_PUBLIC_KEY |
empty or pk_… |
Optional server-side key for the marketing contact-form proxy |
NODEDR_SUBMIT_SECRET_KEY |
empty or sk_… |
Optional HMAC signing for that upstream request — never commit real values |
- Open
/register(orPOST /api/v1/auth/register) and create your first account. Each instance supports exactly one account — once it exists,/registerredirects to/loginand the API rejects furtherPOST /auth/registercalls with403. - Log in at
/login. - Open Dashboard — your form API key is shown there, with a Default inbox project created automatically.
- Use that
api_keyon every website integration (details below). Add extra Projects only if you want separate ingest keys or organization.
JSON submissions work without any storage setup. Configure external S3 per project only when you need presigned file uploads.
For servers behind CGNAT, or when you want Cloudflare in front:
export TUNNEL_TOKEN="your-token"
docker compose --profile tunnel up -dThe cloudflared service depends on Nginx — point your tunnel's DNS/config at this stack.
Authoritative route list: apps/api/internal/httpapi/server.go. Full request/response contract: docs/api.md.
| Area | Method | Path | Auth |
|---|---|---|---|
| Bootstrap | GET | /api/v1/system/bootstrap-status |
None |
| Health | GET | /api/v1/system/health |
None |
| Auth | POST | /api/v1/auth/register, /auth/login, /auth/refresh, /auth/logout |
None |
| Submit | POST | /api/submit |
Header x-api-key |
| Projects | GET, POST | /api/v1/projects |
Bearer |
| Project | PATCH | /api/v1/projects/{id} |
Bearer |
| Submissions | GET | /api/v1/projects/{id}/submissions |
Bearer |
| Bulk delete | DELETE | /api/v1/projects/{id}/submissions/bulk |
Bearer |
| Presign | POST | /api/v1/uploads/presign |
Bearer |
| Export | GET | `/api/v1/projects/{id}/export?format=xlsx | pdf` |
| Security | PUT | /api/v1/users/me/password |
Bearer |
| Security | POST | /api/v1/users/me/api-key/rotate |
Bearer |
| Security | POST | /api/v1/users/me/projects/rotate-keys |
Bearer |
1. Get your API key — after login, open Projects and copy a project public key (pk_live_...). Use it as x-api-key when posting to /api/submit.
2. CORS, if your form lives on another domain — if your browser JS runs on https://client.example.com and calls Submify on https://api.example.com, set:
ALLOWED_ORIGINS=https://client.example.comComma-separate multiple origins; restart the API container after changing env.
3. Recommended JSON body:
{
"data": { "name": "Jane", "email": "jane@example.com", "message": "Hello" },
"files": []
}Flat objects (without data / files) are also accepted and stored as-is.
4. fetch from the browser:
const API_KEY = "<your project public key>";
const SUBMIT_URL = "https://your-submify-host:2512/api/submit";
await fetch(SUBMIT_URL, {
method: "POST",
headers: { "Content-Type": "application/json", "x-api-key": API_KEY },
body: JSON.stringify({
data: { name: "Jane", email: "jane@example.com", message: "Hi" },
files: []
})
});5. Server-side proxy — call Submify from your own backend with the same POST /api/submit contract so the key never reaches the browser.
This repo's own Next.js dashboard (
apps/web) ships an example of this pattern — a contact form posting through a Route Handler. See/docs/contact-proxyin the running app for the full guide, including a copy-paste prompt for AI coding assistants.
If you're wiring Submify into a site using Cursor, Claude Code, Copilot, or ChatGPT, paste this prompt and fill in the bracketed parts:
Integrate Submify form submission into this project.
Submify is a self-hosted form backend. My instance's API base is [https://your-submify-host:2512/api]. My project's public API key is [pk_live_xxx] (treat it like a public site key, not a secret — it's safe in client code, but read it from an env var, not hardcoded).
Requirements:
1. Add a submit handler (server-side route/action if this framework supports one, otherwise a client-side fetch) that POSTs JSON to `${API_BASE}/submit` with header `x-api-key: <the public key>` and body `{ "data": { ...form fields }, "files": [] }`.
2. Validate the form client-side before sending (required fields, email format) and show inline success/error states based on the HTTP status (`200` success, `400` validation error from Submify, `401` bad key, `429` rate limited).
3. Add a hidden honeypot field (e.g. `_gotcha`) to the form; if it has a value, skip the request and show success anyway (silently drops bot submissions without calling Submify).
4. Do not log or expose the API key in client-visible source if a server-side option exists for this framework; prefer an env var named `SUBMIFY_API_KEY` (or `NEXT_PUBLIC_SUBMIFY_API_KEY` only if this must run fully client-side and the project has no backend).
5. Keep the request body under common upload limits — if the form has file inputs, use Submify's presign flow (`POST /api/v1/uploads/presign`, then `PUT` the file to the returned `upload_url`, then include `object_key` in `files` on the submit call) instead of inlining file bytes.
Match this project's existing form/component conventions and error-handling style rather than introducing a new pattern.
That's enough context for most assistants to wire up a working integration without you re-explaining the API shape each time.
Rate limits are tiered so logged-in dashboard use isn't punished by anonymous caps:
GET /system/bootstrap-statusandGET /system/health— unlimited (use your own WAF/monitoring if needed)- Login / refresh / logout / setup — per client IP, default 25/min
POST /submit— per IP and per API key, default 90/min and 180/min- All Bearer-authenticated routes — per user id, default 600/min
Nginx forwards X-Forwarded-For; the API trusts it only from TRUSTED_PROXIES. Tune the rate-limit env vars if legitimate traffic hits 429.
Form JSON always goes to PostgreSQL. File uploads are optional and use any external S3-compatible provider — nothing is bundled, so you bring your own bucket.
1. Configure storage — in Settings (account-level) or per-Project, set:
s3_endpoint— provider API endpoints3_bucket— your bucket names3_access_key/s3_secret_key— provider-issued API credentials (not root/admin credentials)
Project-level credentials take priority; user-level (account) credentials are the fallback, for backward compatibility.
2. Make sure the API container can reach the endpoint — not just your browser:
| Provider | Typical endpoint |
|---|---|
| AWS S3 | https://s3.<region>.amazonaws.com |
| Cloudflare R2 | https://<accountid>.r2.cloudflarestorage.com |
| Self-hosted MinIO | http://minio:9000 (Docker network) or a reachable host URL |
The access key needs PutObject (and related object) permissions on the target bucket; outbound network access from the api container to that endpoint must be allowed.
3. Upload flow:
const presign = await fetch('/api/v1/uploads/presign', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ project_id: projectId, filename: file.name, content_type: file.type, size: file.size })
}).then((r) => r.json());
await fetch(presign.upload_url, { method: 'PUT', headers: { 'Content-Type': file.type }, body: file });
await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': projectPublicKey },
body: JSON.stringify({
data: { name: 'Jane Doe', email: 'jane@example.com' },
files: [{ object_key: presign.object_key, name: file.name, content_type: file.type }]
})
});- Your app calls
POST /api/v1/uploads/presignwithproject_id,filename,content_type,size. - Submify returns a short-lived
upload_urlandobject_key. - The browser/client
PUTs the file bytes directly toupload_url— this is the actual S3 upload. - Your app then sends the normal
POST /api/submit, includingobject_keyinfiles. - Submify stores that metadata alongside the submission.
Common mistakes: wrong endpoint URL for your region/account, credentials missing bucket permissions, bucket name typos, calling /uploads/presign without Bearer auth (or for a project you don't own), unsupported MIME types, files larger than UPLOAD_MAX_SIZE_BYTES, or putting secret keys in browser-exposed code instead of signing server-side.
- Log in.
- Copy your account form API key from the dashboard (one key, every site).
- Point website forms at
POST /api/submitwithx-api-key: <project_public_key>. - Review submissions in the Default inbox (or additional projects for separation).
- Export XLSX or PDF; use bulk delete to stay under the per-project cap.
Sometimes the person who needs to see submissions isn't you — it's the client whose site the form lives on. The client portal gives each project a public, password-protected URL where that client can view and export submissions — and nothing else. They never get a dashboard account, API keys, delete rights, or visibility into your other projects.
- Every project has its own URL:
https://your-host:2512/<slug>(e.g./acme-contact). The slug is generated from the project name and can be renamed from Projects. - Auto-generated password. Creating a project mints a strong random portal password, shown once so you can copy and share it. Only its Argon2id hash is stored — regenerate a fresh one any time, or set your own from the dashboard.
- You control access. Enable/disable the portal per project, rotate or clear the password, and change the slug — all from the project card in Projects.
- You skip the password. Opening your own project's portal while signed in to the dashboard lets you straight through (no password prompt).
How it's kept safe: portal sessions are project-scoped, stored in an HttpOnly cookie (never
exposed to JavaScript), and can only reach the read-only /(slug) view and export endpoints. Login
is rate-limited per project (on top of the per-IP limit) to blunt brute-force guessing, and every
portal response is sent Cache-Control: no-store. Slugs are validated against a reserved-word list
so a portal can never shadow a dashboard route.
Share the URL + password with your client. To revoke access, disable the portal or regenerate the password from the project card.
| Item | Value |
|---|---|
| Submissions per project | 5000 (then 429) |
| Password hashing | Argon2id |
| Sessions | JWT access + refresh, Bearer auth for dashboard APIs |
| Rate limiting | Tiered (see Rate limits); authed users limited per account, not per shared IP |
| Tenant isolation | Project ownership checked on every authenticated, project-scoped route |
Use HTTPS in production. The account api_key is meant to be embedded in public sites (like a reCAPTCHA site key, not a secret). If it leaks, rotate it from Settings immediately — project-level keys can be rotated individually or all at once.
Settings controls: change login password, rotate account API key, rotate all project keys at once, update S3/storage credentials, save host bind/port preferences.
Submify runs on Go 1.26 and Node.js 24 LTS, with dependencies kept current — apps/api/go.sum and apps/web/package-lock.json are committed so every build resolves the exact same, checksum-verified dependency tree (no silent drift between your build and the one this repo was tested against).
Before each release, dependencies are checked with the official scanners:
# Go API — apps/api
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
# Next.js dashboard — apps/web
npm auditBoth currently report zero known vulnerabilities. If you fork this project, re-run both commands after any dependency bump, and keep go.sum / package-lock.json committed so govulncheck and npm audit are checking what actually ships.
If you discover a security issue, please report it privately to info@nodedr.com rather than opening a public issue.
Two common situations call for storing secrets in GitHub → Settings → Secrets and variables → Actions → New repository secret instead of committing them:
If a site you deploy via GitHub Actions submits forms to Submify, store the project's public key (and, for the Nodedr contact-proxy pattern, the secret key) as repository secrets rather than hardcoding them:
| Secret name | Value |
|---|---|
SUBMIFY_API_KEY |
Project public key (pk_live_...) from your dashboard |
NODEDR_SUBMIT_PUBLIC_KEY |
Only if using the apps/web contact-proxy pattern in your own site |
NODEDR_SUBMIT_SECRET_KEY |
Same — never expose this one to the client bundle |
Reference them in a workflow step that builds your site's Docker image or static export:
- name: Build site
env:
SUBMIFY_API_KEY: ${{ secrets.SUBMIFY_API_KEY }}
run: docker build --build-arg SUBMIFY_API_KEY="$SUBMIFY_API_KEY" -t my-site .${{ secrets.NAME }} is masked in logs automatically — never echo a secret directly, and never pass it as a plain --build-arg without RUN --mount=type=secret in the Dockerfile if the layer could be inspected later.
If you run Submify on a self-hosted runner (matching the runs-on: [self-hosted, ...] pattern used for Docker Compose deploys), store these as repository (or environment) secrets instead of relying on docker-compose.yml's built-in defaults:
| Secret name | Used for |
|---|---|
POSTGRES_PASSWORD |
Database password (must stay stable across redeploys — changing it without migrating ./data/postgres locks you out) |
JWT_SECRET |
Session signing key, ≥32 random characters |
TUNNEL_TOKEN |
Only if using the Cloudflare Tunnel profile |
S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY |
Only if you provision S3 credentials at deploy time rather than via the dashboard |
A deploy step then writes them into .env on the runner before bringing the stack up:
- name: Write secrets and deploy
env:
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
run: |
cat > .env <<EOF
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
JWT_SECRET=${JWT_SECRET}
EOF
./scripts/compose-up.sh up --build -dNever commit the generated .env — it's already excluded via .gitignore. For a single VPS without CI, the auto-generated .env.auto from ./scripts/compose-up.sh is simpler and equally secure; reach for GitHub Actions secrets only once a CI/CD pipeline is doing the deploy.
Logs: docker compose logs -f [api|nginx|web|db]
Update (pull latest, rebuild, prune):
cd Submify && git fetch origin && git reset --hard origin/main && docker compose up --build -d && sh ./scripts/prune-docker.shIf you installed with ./scripts/compose-up.sh, substitute it for the bare docker compose up --build -d call so env files stay aligned. This updates code only — it never touches ./data/, .env, or .env.auto (all gitignored). git reset --hard is used instead of git pull because history on main is occasionally rewritten upstream; a plain git pull fails with fatal: Need to specify how to reconcile divergent branches whenever that happens. Since a deployed instance has no local commits of its own to preserve, resetting to match origin/main exactly is the safe, always-works update path.
Backups: ./data/postgres is the only thing you need to back up; it lives next to docker-compose.yml, not inside the API image.
Disk usage: rebuilds accumulate image/build cache (not database growth). Run sh ./scripts/prune-docker.sh periodically — it never touches volumes or ./data/.
| Symptom | What to check |
|---|---|
docker: 'compose' is not a docker command |
Docker was installed from Debian's docker.io package — the Compose v2 plugin is not included. See the Debian fix in the Requirements section |
trying to overwrite /usr/libexec/docker/cli-plugins/docker-buildx |
Package conflict between Debian's docker-buildx and Docker's official docker-buildx-plugin. Run sudo apt remove docker-buildx then sudo apt install docker-buildx-plugin docker-compose-plugin |
API exits: JWT_SECRET must be set… |
With GIN_MODE=release, the secret must be ≥32 characters. Set it in .env, or run via ./scripts/compose-up.sh so .env.auto supplies one |
| Postgres auth errors after an upgrade | POSTGRES_PASSWORD no longer matches the existing ./data/postgres cluster — restore the original password, or start from a fresh data dir if you accept losing the DB |
git pull fails: fatal: Need to specify how to reconcile divergent branches (or origin/main shows forced update) |
History on main was rewritten upstream. Use the update command above (git fetch origin && git reset --hard origin/main) instead of git pull — safe here since .env, .env.auto, and ./data/ are all gitignored and untouched by it |
Permission denied on ./scripts/prune-docker.sh |
Run sh ./scripts/prune-docker.sh, or chmod +x scripts/prune-docker.sh |
docker compose logs -f looks "stuck" |
-f follows the stream until Ctrl+C — that's expected, not frozen. Omit -f for a one-shot dump |
docker compose build fails |
Re-run with --progress=plain and read the error block. On a small VPS, try --parallel 1 or add swap if the build OOMs |
| Nothing on port 2512 | Check firewall, docker compose ps, and Nginx logs |
401 on submit |
x-api-key must match a valid account api_key or project public_api_key |
429 on submit |
Per-project 5000 cap, or submit IP/key rate limits |
| CORS errors from the browser | ALLOWED_ORIGINS must include your site's exact origin (scheme + host + port) |
| Presign / upload fails | Confirm S3 credentials in Settings or Projects, that the API container can reach the endpoint, and that the file matches UPLOAD_MAX_SIZE_BYTES / UPLOAD_ALLOWED_MIME — see common mistakes |
Running tests: go test ./... from apps/api covers password hashing, JWT, and related unit tests.
Submify is open source under the GNU Affero General Public License v3.0 (AGPL-3.0) — see LICENSE. Use it, modify it, self-host it, and build commercial products on it freely — if you run a modified version as a network service for others, you must make that modified source available to those users under the same license. Third-party dependency licenses are listed in THIRD_PARTY.md.
Submify is built by NODEDR INFOTECH PRIVATE LIMITED.
- Lead Developer & Founder: Raktim Ranjit
- Company: NODEDR INFOTECH PRIVATE LIMITED
- Website: www.nodedr.com
- Repository: github.com/Raktim94/Submify
- API reference: docs/api.md
- Deployment guide: docs/deployment.md
- Third-party licenses: THIRD_PARTY.md