Deploying
The two ready-made Compose stacks, backups, and running it anywhere else.
gostore runs as a small Docker Compose stack on one server: the store, Postgres
beside it, and something to let requests in. Two ready-made deployments ship in
the repository's deploy/ directory. Copy one to the server, fill in
one .env, and start it with docker compose up -d.
| Standard | Tunnel | |
|---|---|---|
| How requests get in | Caddy, with a Let's Encrypt certificate | A Cloudflare Tunnel — no open ports at all |
| Images and files | Separate public and private R2 buckets | Separate public and private R2 buckets |
| Any SMTP provider | Microsoft 365, through Microsoft Graph | |
| Pick it when | You have a VPS with a public address | The server has no public address, or you would rather open no ports; your domain is on Cloudflare |
Each link above is a step-by-step guide, from an empty server to a claimed admin account and nightly backups.
The .env
Everything a deployment needs, credentials included, goes in the .env beside
its compose.yaml, readable by you alone:
cp .env.example .env && chmod 600 .envEach deployment's .env.example holds only what it needs, and compose.yaml
derives what it can — BASE_URL from DOMAIN, DATABASE_URL from
POSTGRES_PASSWORD — so values that must agree cannot disagree. Both require a
generated EMAIL_QUEUE_KEY; keep it with your secrets backup. Any other
setting can be added to the same file.
After editing .env, run docker compose up -d — that recreates the server
with the new values. docker compose restart keeps the old ones.
A theme
Both deployments mount a theme/ directory beside compose.yaml into the
server at /theme. Put a theme there and point TEMPLATE_DIR and STATIC_DIR
at /theme/templates and /theme/static; see
Using a theme in a deployment.
Backups
Both take a nightly database backup to the server's own disk with the
backup.sh in their directory. That covers a bad migration or a mistaken
delete, but not losing the server: Backups copies them
off it, and covers restoring and testing a restore.
Before it takes real money
- Set
PAYFAST_SANDBOXexplicitly — the deployments refuse to start without it — and use your own merchant credentials. See Payments. - Leave
CLIENT_IP_SOURCEas the deployment sets it —forwardedbehind Caddy,cloudflarebehind the tunnel. Elsewhere, set it to match whatever is in front of the server; see Configuration. - Claim the first administrator straight away, before anybody else can reach
/admin/setup. - Leave
THEME_RELOADoff.
Publishing an image
The image is built on a workstation and pushed to GHCR by hand with
make publish, usually from a tagged release. It refuses to publish from a
dirty tree or an untagged commit. GHCR makes a new package private, so set it
public after the first publish or the server cannot pull it.
Running it anywhere else
The binary is static and the image is distroless, so gostore runs anywhere a
container does. It reads PORT from the environment, takes a single
DATABASE_URL, logs JSON to stdout, and answers GET /healthz.
Migrations run on boot, before the server accepts traffic, behind a Postgres
advisory lock so several instances starting at once cannot race. To migrate as a
deploy step of its own, run the same image with -migrate first. It needs only
DATABASE_URL: a schema change has no reason to be trusted with the live
merchant key.
gostore -check-config && gostore -migrate && exec gostore-check-config validates the whole environment, touches nothing and exits, so a
missing payment key fails the deploy while the database is still untouched.
-migrate-status prints which migrations have been applied. Migrations only go
forward in production: rolling a schema back over live orders loses money, not
just columns.
With more than one instance, use object storage for images — two instances do not share a directory.
Logs
JSON to stdout and nothing else, which docker compose logs and any log
collector read with no setup. Every response carries a request id, echoed as
X-Request-Id, printed on error pages and attached to every log line for that
request. LOG_FORMAT=gcp renames the fields so Google Cloud's severity filters
work.