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
- Create a PostgreSQL database.
- Generate secrets and write your environment file.
- Build the Docker image.
- Start the container with a volume for files. Migrations run automatically when it starts.
- Put an HTTPS reverse proxy in front of it.
- Check
/api/health, then sign in as the first admin.
Prepare the environment
Generate a secret for sign-in sessions:
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:
# 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.
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_URLis set,apps/app/start.shrunspnpm --filter @adcraft/db db:migratebefore 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_URLfrom.envat the project root):pnpm --filter @adcraft/db db:migrateOne-off, with the image, before switching traffic to a new version:
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:
docker run -d --name adcraft --restart unless-stopped \
--env-file .env.production \
-v adcraft-data:/data \
-p 127.0.0.1:3000:3000 \
adcraftOn 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
Hostheader andX-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:
ads.example.com {
reverse_proxy 127.0.0.1:3000
}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
curl https://ads.example.com/api/health{"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.
jobsis informational: it readsfalsewhen 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.
versionisNEXT_PUBLIC_APP_VERSIONif 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 todb/inR2_BACKUP_BUCKET, or inR2_BUCKETif no backup bucket is set. - Otherwise they are written to
BACKUP_DIR. In Docker, setBACKUP_DIR=/data/backupsso 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:
pg_dump --no-owner --no-privileges "$DATABASE_URL" | gzip > adcraft-$(date -u +%Y%m%dT%H%M%SZ).sql.gzRestore with:
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/healthwith 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, thenpnpm --filter @adcraft/db db:migrateandpnpm --filter @adcraft/app startunder a process manager. The app reads.envfrom the project root. Install the Chromium libraries listed in theDockerfileif you render video. - Vercel:
apps/app/vercel.jsonis 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
- Back up the database and the
/datavolume. Keep your environment file unchanged, especiallyAUTH_SECRETandTOKEN_ENCRYPTION_KEY. - Read the Changelog.
- Replace the code with the new release (keep your
.envfiles) and rebuild the image. - Replace the container. New migrations are applied as it starts.
- 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.