Skip to content

Repository files navigation

Submify

Submify

Self-hosted Form Backend as a Service (FBaaS). One Docker stack, one API key, every form on every site you own.

License: AGPL v3 Go Next.js Docker Built by Nodedr

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

How it works

  1. You create an account and a project. Registering gives you an account-level api_key and a default project with its own pk_live_... public key.
  2. You embed that key in your website's form. No SDK needed — any HTML form or fetch() call can POST JSON straight to https://your-host:2512/api/submit with the key in the x-api-key header.
  3. 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.
  4. 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.
  5. 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.

Why Submify

  • One API key, every site. A single account api_key works 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.

What you get

  • JSON form submission API — one primary api_key per 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.


Quick start

Requires Docker Engine and the Compose v2 plugin.

curl -fsSL https://raw.githubusercontent.com/Raktim94/Submify/main/install.sh | bash

That 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 --build

See 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.

Manual install

git clone https://github.com/Raktim94/Submify.git
cd Submify
./scripts/compose-up.sh up --build -d

Windows (PowerShell):

git clone https://github.com/Raktim94/Submify.git
cd Submify
.\scripts\Compose-Up.ps1 up --build -d

No .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

Table of contents

  1. How it works
  2. Architecture
  3. Requirements
  4. Quick start
  5. URLs and ports
  6. Accessing via network IP
  7. Configuration and environment variables
  8. First-time access
  9. Optional: Cloudflare Tunnel
  10. API overview
  11. Connecting a client website (forms)
  12. Integrating with an AI coding assistant
  13. External S3 uploads (optional)
  14. Dashboard workflow
  15. Client portal (share view-only access)
  16. Limits and security defaults
  17. Security and vulnerability scanning
  18. Using GitHub Actions secrets
  19. Operations: logs, backup, updates
  20. Troubleshooting
  21. License
  22. Developer & ownership

Architecture

                ┌────────────────────────┐
  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.


Requirements

  • 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.

Before you install (host prep)

Linux (recommended for servers):

  1. Install Docker Engine + Compose plugin (see the official docs).
  2. Verify: docker --version and docker compose version.
  3. Add your user to the docker group so you don't need sudo for 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. Running docker compose version will return docker: 'compose' is not a docker command. Follow the fix below before running the installer.

Fix: Docker Compose v2 on Debian (docker.io install)

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 update

Step 2 — Remove the conflicting Debian buildx package:

sudo apt remove docker-buildx

Step 3 — Install the official Compose and Buildx plugins:

sudo apt install docker-buildx-plugin docker-compose-plugin

If apt install still reports broken packages, run:

sudo apt --fix-broken install
sudo apt install docker-buildx-plugin docker-compose-plugin

Step 4 — Verify and re-run the installer:

docker buildx version
docker compose version
curl -fsSL https://raw.githubusercontent.com/Raktim94/Submify/main/install.sh | bash

URLs and ports (browser vs. containers)

Nginx 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).

Accessing Submify from another device (LAN / network IP)

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:2512

Step 2 — Restart the stack

docker compose up -d

Step 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 ACCEPT

Now 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.


Configuration and environment variables

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

First-time access

  1. Open /register (or POST /api/v1/auth/register) and create your first account. Each instance supports exactly one account — once it exists, /register redirects to /login and the API rejects further POST /auth/register calls with 403.
  2. Log in at /login.
  3. Open Dashboard — your form API key is shown there, with a Default inbox project created automatically.
  4. Use that api_key on 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.


Optional: Cloudflare Tunnel

For servers behind CGNAT, or when you want Cloudflare in front:

export TUNNEL_TOKEN="your-token"
docker compose --profile tunnel up -d

The cloudflared service depends on Nginx — point your tunnel's DNS/config at this stack.


API overview

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

Connecting a client website (forms)

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.com

Comma-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-proxy in the running app for the full guide, including a copy-paste prompt for AI coding assistants.

Integrating with an AI coding assistant

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-status and GET /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.


External S3 uploads (optional)

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 endpoint
  • s3_bucket — your bucket name
  • s3_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 }]
  })
});
  1. Your app calls POST /api/v1/uploads/presign with project_id, filename, content_type, size.
  2. Submify returns a short-lived upload_url and object_key.
  3. The browser/client PUTs the file bytes directly to upload_url — this is the actual S3 upload.
  4. Your app then sends the normal POST /api/submit, including object_key in files.
  5. 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.


Dashboard workflow

  1. Log in.
  2. Copy your account form API key from the dashboard (one key, every site).
  3. Point website forms at POST /api/submit with x-api-key: <project_public_key>.
  4. Review submissions in the Default inbox (or additional projects for separation).
  5. Export XLSX or PDF; use bulk delete to stay under the per-project cap.

Client portal (share view-only access)

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.


Limits and security defaults

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.


Security and vulnerability scanning

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 audit

Both 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.


Using GitHub Actions secrets

Two common situations call for storing secrets in GitHub → Settings → Secrets and variables → Actions → New repository secret instead of committing them:

1. Your website's CI calls Submify

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.

2. You deploy Submify itself via GitHub Actions

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 -d

Never 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.


Operations: logs, backup, updates

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.sh

If 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/.


Troubleshooting

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.


License

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.


Developer & ownership

Submify is built by NODEDR INFOTECH PRIVATE LIMITED.

  • Lead Developer & Founder: Raktim Ranjit
  • Company: NODEDR INFOTECH PRIVATE LIMITED
  • Website: www.nodedr.com

Links

About

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

Topics

Resources

Security policy

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages