WWebBuddyMarketplace
Install and run

Installation and deployment

Deploy Adcraft with Docker on a VPS behind an HTTPS reverse proxy, with migrations, durable storage, health checks, backups, rebranding and safe updates.

8 min readUpdated Oct 17, 2026Applies to Adcraft 1.0.0

On this page

This page takes a fresh server to a working production install. The package also contains documentation/DEPLOY.md with the same steps in short form.

Note

Read Requirements first. In particular: one always-on process, PostgreSQL, durable file storage, HTTPS and a sign-in method (Resend or Google).

Overview

  1. Create a PostgreSQL database.
  2. Generate secrets and write your environment file.
  3. Build the Docker image.
  4. Start the container with a volume for files. Migrations run automatically when it starts.
  5. Put an HTTPS reverse proxy in front of it.
  6. Check /api/health, then sign in as the first admin.

Prepare the environment

Generate a secret for sign-in sessions:

Terminal
openssl rand -base64 32   # AUTH_SECRET
openssl rand -base64 32   # TOKEN_ENCRYPTION_KEY (optional, recommended)

Create .env.production and keep it out of version control:

.env
# Core
DATABASE_URL=postgres://user:password@db-host:5432/adcraft
AUTH_SECRET=...
AUTH_URL=https://ads.example.com
ADMIN_EMAILS=you@example.com
TOKEN_ENCRYPTION_KEY=...

# Sign-in: Resend magic links and/or Google
RESEND_API_KEY=re_...
EMAIL_FROM=Adcraft <login@ads.example.com>
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=

# Contact addresses on the legal pages
SUPPORT_EMAIL=support@ads.example.com
PRIVACY_EMAIL=privacy@ads.example.com

# AI providers: add the ones you use
ANTHROPIC_API_KEY=sk-ant-...
FAL_KEY=...

Why each one matters:

Variable If you leave it out
DATABASE_URL The container tries to use the embedded evaluation database. Set it for any real install.
AUTH_SECRET Nobody can sign in. It signs session tokens and, without TOKEN_ENCRYPTION_KEY, encrypts ad-platform tokens.
AUTH_URL In production, sign-in refuses requests unless it knows your public address. Magic links, invitations, share links, OAuth callbacks and API file links are built from it.
RESEND_API_KEY or AUTH_GOOGLE_* Nobody can sign in. The development sign-in box is never shown in production.
EMAIL_FROM Email goes out from Resend's test sender, onboarding@resend.dev, which is only for testing.
SUPPORT_EMAIL, PRIVACY_EMAIL The privacy policy, Settings → Privacy and the suspended-workspace page show example.com placeholders, and deletion requests are emailed to privacy@example.com.
ADMIN_EMAILS Nobody can open /admin.
TOKEN_ENCRYPTION_KEY Ad-platform tokens are encrypted with a key derived from AUTH_SECRET, so changing AUTH_SECRET later breaks connected ad accounts.

The full list is on Configuration.

Warning

Keep AUTH_SECRET and TOKEN_ENCRYPTION_KEY safe and unchanged. Changing them signs everyone out and makes stored ad-platform tokens unreadable, so every connected ad account would have to be reconnected.

Build the image

The Dockerfile in the package root builds a single image on node:22-bookworm-slim. It installs the libraries headless Chromium needs for video rendering, downloads Chrome Headless Shell at build time so the first render does not wait for it, installs the PostgreSQL 18 client for backups, and builds the app.

Terminal
docker build -t adcraft .

The image runs as an unprivileged user (uid 10001), listens on port 3000, and starts with apps/app/start.sh.

Migrations

The database schema is a set of SQL migrations in packages/db/drizzle, applied with drizzle-kit migrate.

  • In Docker, they run on every start. When DATABASE_URL is set, apps/app/start.sh runs pnpm --filter @adcraft/db db:migrate before starting the server. Already-applied migrations are skipped, so this is safe on every restart and every update.

  • By hand, from the project folder (it reads DATABASE_URL from .env at the project root):

    Terminal
    pnpm --filter @adcraft/db db:migrate
  • One-off, with the image, before switching traffic to a new version:

    Terminal
    docker run --rm --env-file .env.production adcraft pnpm --filter @adcraft/db db:migrate

Important

Run a single instance. Every container migrates on start, and two starting at the same moment would both try to apply new migrations.

The embedded evaluation database (no DATABASE_URL) migrates itself on first use; start.sh skips the step and prints a warning.

Start the container

Mount a volume at /data for uploads and generated media, and bind the port to localhost so only the proxy can reach it:

Terminal
docker run -d --name adcraft --restart unless-stopped \
  --env-file .env.production \
  -v adcraft-data:/data \
  -p 127.0.0.1:3000:3000 \
  adcraft

On first start, start.sh hands /data/files to the app user, drops root privileges, runs the migrations and starts the server. Logs go to standard output: docker logs -f adcraft.

Warning

Without the /data volume (or Cloudflare R2), every uploaded and generated file is lost when the container is replaced. See File storage.

If you want to try the image without PostgreSQL, set PGLITE_DATA_DIR=/data/pglite so the embedded database lives on the mounted volume. This is for evaluation only.

HTTPS and the reverse proxy

Adcraft does not terminate TLS. Put it behind Caddy, nginx, a load balancer or your platform's HTTPS. The proxy must:

  • terminate TLS for the domain in AUTH_URL;
  • forward the original Host header and X-Forwarded-Proto, which Stripe Checkout uses to send customers back;
  • accept large uploads: digital-twin footage can be up to 600 MB, and product photos up to 15 MB;
  • allow slow requests: uploads of large files take time.

In production the app sends a Strict-Transport-Security header itself, so serve it only over HTTPS.

Caddy

Caddy obtains certificates automatically and has no upload size limit by default:

Caddyfile
ads.example.com {
  reverse_proxy 127.0.0.1:3000
}

nginx

nginx
server {
  listen 443 ssl http2;
  server_name ads.example.com;

  ssl_certificate     /etc/letsencrypt/live/ads.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/ads.example.com/privkey.pem;

  # Digital-twin footage uploads can be up to 600 MB.
  client_max_body_size 650m;

  location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_read_timeout 300s;
    proxy_send_timeout 300s;
    proxy_request_buffering off;
  }
}

server {
  listen 80;
  server_name ads.example.com;
  return 301 https://$host$request_uri;
}

Check the deployment

Terminal
curl https://ads.example.com/api/health
JSON
{"ok":true,"version":"dev","checks":{"database":{"ok":true,"ms":3},"storage":{"ok":true,"ms":2},"jobs":{"ok":false,"ms":0}},"at":"2026-10-17T10:00:00.000Z"}
  • It runs a real database query and writes and deletes a small file in storage. It returns 200 when both work and 503 when either fails, so use it as your host's health check.
  • jobs is informational: it reads false when Inngest is not configured and jobs run inside the web process. It does not affect the status code.
  • Error messages for a failing check are only shown to a signed-in platform admin.
  • version is NEXT_PUBLIC_APP_VERSION if you set it.

Then sign in with an address from ADMIN_EMAILS and follow First admin and the admin panel.

Background jobs

Long work (concepts, image and video generation, cutouts, publishing, performance syncs) runs as background jobs. There are two modes:

Without Inngest (default) With Inngest Cloud
Setup Nothing Create an Inngest app pointed at https://<your-domain>/api/inngest, then set INNGEST_EVENT_KEY and INNGEST_SIGNING_KEY
Where jobs run Inside the web process, started by the request and finished in the background Inngest calls back into your app, so jobs still run on your server
Retries None. A failed job is logged and the generation is marked failed. 0 to 3 retries depending on the job, with concurrency limits
A restart during a job The job is lost Inngest retries it
Hourly performance sync Not run; use Sync now Every hour
Nightly database backup Not run 03:15 UTC

For a small install, inline mode is fine. For a SaaS, use Inngest.

Note

Leave INNGEST_DEV unset in production. It is only for pointing a local install at the Inngest Dev Server (pnpm inngest).

Backups

When Inngest is configured, a nightly job at 03:15 UTC dumps the database with pg_dump, gzips it and keeps it for BACKUP_RETENTION_DAYS days (default 14):

  • With the four R2_* variables set, dumps go to db/ in R2_BACKUP_BUCKET, or in R2_BUCKET if no backup bucket is set.
  • Otherwise they are written to BACKUP_DIR. In Docker, set BACKUP_DIR=/data/backups so they land on the mounted volume, and copy them off the server.

pg_dump must match your PostgreSQL server's major version. The image includes the PostgreSQL 18 client; for another server version, schedule your own backups instead.

Without Inngest, nothing runs on a schedule. Use your database provider's backups, or a cron job on the host:

Terminal
pg_dump --no-owner --no-privileges "$DATABASE_URL" | gzip > adcraft-$(date -u +%Y%m%dT%H%M%SZ).sql.gz

Restore with:

Terminal
gunzip -c adcraft-<timestamp>.sql.gz | psql "$DATABASE_URL"

Back up the /data volume (or rely on R2) as well: the database only holds references to files.

Other hosts

  • Container platforms (Railway, Render, Fly.io and similar): deploy the Dockerfile, attach PostgreSQL, mount a volume at /data, set the health check to /api/health with a generous timeout (the first start runs migrations), keep one replica and turn off auto-stop.
  • Without Docker: on a VM with Node.js 22 and pnpm, run corepack enable, pnpm install --frozen-lockfile, pnpm build, then pnpm --filter @adcraft/db db:migrate and pnpm --filter @adcraft/app start under a process manager. The app reads .env from the project root. Install the Chromium libraries listed in the Dockerfile if you render video.
  • Vercel: apps/app/vercel.json is included, but video rendering, website screenshots and inline jobs do not fit serverless functions. Use it only for the web tier with Inngest and a separate always-on worker; a single container is much simpler.

Updating to a new version

  1. Back up the database and the /data volume. Keep your environment file unchanged, especially AUTH_SECRET and TOKEN_ENCRYPTION_KEY.
  2. Read the Changelog.
  3. Replace the code with the new release (keep your .env files) and rebuild the image.
  4. Replace the container. New migrations are applied as it starts.
  5. Check /api/health.

If you changed the source, apply the new release on a branch and merge your changes before step 3.

Rebranding

The product name and look live in a handful of places:

What Where
Colours and type packages/ui/src/tokens.css and tokens.ts
Wordmark and logo mark apps/app/src/components/spark.tsx
Favicons and touch icons apps/app/public
Marketing site (/, /showcase, /pricing) apps/app/public: plain HTML, CSS and app.js. It contains the product name, the plan prices and credits, the FAQ and sample brands.
Legal page wording apps/app/src/lib/legal.ts
Contact addresses SUPPORT_EMAIL and PRIVACY_EMAIL
Sender address EMAIL_FROM

Then search the project for "Adcraft" to catch page titles and email copy. The pictures on the marketing site are editable SVG placeholders; replace them with your own licensed images.

Important

The privacy policy, terms and DPA pages are starting points written for the original service. Have them reviewed and rewritten for your business before you take customers. Their contact addresses and site name come from SUPPORT_EMAIL, PRIVACY_EMAIL and AUTH_URL, so set those rather than editing addresses in the code.

Logs

Logs go to standard output and standard error: docker logs adcraft, or your platform's log viewer. Unhandled request errors are also listed at Admin → Errors for 14 days, and are sent to Sentry when SENTRY_DSN is set. Errors inside background jobs that run inline appear in the logs only.