DEV Community

knot crochet
knot crochet

Posted on Originally published at autonnel.com

I Made a Self-Hosted Checkout Stack That Boots With One Command and Zero Config

Most self-hosted software fails its user in the first ten minutes, and it fails in a specific way: .env.example has forty variables, eight of them are required, and three of those require you to go create an account somewhere else before the app will boot.

I set a hard constraint for mine: docker compose up, open a browser, done. No Node install, no external Postgres, no S3 bucket, no SMTP credentials, no generated secrets. Not "fast to configure" - zero configuration to boot.

That constraint turned out to be an architecture decision, not a packaging decision, and it changed how config works in the app itself.

The compose file is three services

services:
  db:
    image: postgres:17-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U autonnel -d autonnel"]
      interval: 3s
      retries: 20

  schema:
    image: ghcr.io/example/app:${TAG:-latest}
    depends_on:
      db:
        condition: service_healthy
    command:
      - node_modules/.bin/prisma
      - db
      - push
      - --schema=./prisma/schema.prisma
      - --url
      - *db-url
    restart: "no"

  app:
    image: ghcr.io/example/app:${TAG:-latest}
    depends_on:
      schema:
        condition: service_completed_successfully
    ports: ["${PORT:-4321}:4321"]
Enter fullscreen mode Exit fullscreen mode

The middle one is the piece people skip. schema is a one-shot container that creates or updates tables and exits. app waits on service_completed_successfully, not on service_started.

Two properties fall out of that, and both matter more than the ten lines cost:

  • The app never races the schema. There is no retry loop, no "waiting for database" spinner, no partially-migrated first request.
  • The upgrade path is the same command as the install path. docker compose up after a version bump runs the migration container again before starting the new app. There is no separate migrate step to document, and therefore no step for a user to skip.

The restart: "no" is not decoration. Without it, a one-shot container that exits 0 gets restarted by a default restart policy inherited from anywhere, and you get a migration loop.

The gotcha that only appears in a stripped image

Prisma 7 reads the datasource URL from prisma.config.ts. That file is a build-time artifact and it is not in my runtime image, because the runtime image only contains dist, node_modules, package.json and the schema file.

So the migration command has to pass the URL explicitly:

command: [..., --url, "postgresql://user:pass@db:5432/app"]
Enter fullscreen mode Exit fullscreen mode

That took me longer to diagnose than it should have, because the error is about a missing config file and the cause is an image layer decision made in a different file. It's worth stating the general shape: the smaller you make your runtime image, the more implicit build-time context you have to make explicit at run time. Anything your tooling "just finds" in the repo needs to be passed as a flag once the repo isn't there.

For the same reason, prisma generate runs in the build stages with a dummy URL fallback in the config. Generation never connects to a database, but it will refuse to run without a syntactically valid URL, and there is no database at image-build time.

Zero config and real secrets are not actually in conflict

The uncomfortable part of a zero-config boot is session secrets and credential encryption keys. You can't ask for them and still boot in one command. You can't generate them per-boot or every restart logs everyone out and makes stored provider credentials unreadable.

So: baked development defaults, overridable, with the warning at the point of use rather than in a doc.

AUTH_SESSION_SECRET: ${AUTH_SESSION_SECRET:-dev-only-insecure-session-secret-change-me}
CREDENTIALS_ENCRYPTION_KEY: ${CREDENTIALS_ENCRYPTION_KEY:-ZGV2LW9ubHktaW5zZWN1cmUta2V5LWNoYW5nZS1tZQ==}
Enter fullscreen mode Exit fullscreen mode

Compose reads a neighbouring .env automatically, and values there win, so "make this production-ready" is: create .env, put two openssl rand outputs in it. That instruction lives in a comment at the top of the compose file, next to the sentence that actually gets read:

Rotating either value invalidates existing sessions and makes stored provider credentials unreadable, so generate once and keep them stable.

The default value is literally the string dev-only-insecure-.... If it ever shows up in a support thread or a screenshot, it identifies itself.

Why nothing is required to boot

The zero-config property isn't really a packaging trick. It's downstream of one decision made inside the app: S3 credentials, the email provider, payment keys and the ecommerce backend are not environment variables. They're rows in a config table, edited in the admin UI, with env as a fallback. Every integration is therefore inert until configured, and the features that need one degrade instead of crashing.

That decision has its own trade-offs, enough of them that they don't fit here. For this post the only relevant consequence is the one above: an app whose integrations are all optional at boot is an app that can boot with an empty environment.

The image itself

Three stages, standard shape, one detail worth copying:

  • deps: production dependencies only, plus prisma generate.
  • builder: full dependencies, prisma generate again, then the app build.
  • runner: copies node_modules from deps and dist from builder.

Copying the production module tree from a stage that never installed dev dependencies is what keeps the final image from carrying a build toolchain. It costs one extra install during build and it's worth it.

Plus a healthcheck that hits a real endpoint, USER node, and HOST=0.0.0.0 (without which the server binds to localhost inside the container and the port mapping appears to do nothing - a five-minute mystery everyone gets to solve once).

What I'm not claiming

This is a single Postgres container with a named volume. It is a genuinely good way to run a small deployment on one box, and it is not a highly-available production database. If you're at the point of needing that, point DATABASE_URL at your managed instance and delete the db service - the compose file is designed so that's a two-line edit rather than a fork.

Top comments (0)