DEV Community

Benjamin Touchard
Benjamin Touchard

Posted on Originally published at govuekit.dev

Deploying a Go + Vue SaaS on one VPS with Traefik and Let's Encrypt

A SaaS that compiles into one binary does not need a deployment platform. It
needs a server, a proxy that gets certificates, and a backup you have
restored at least once.

Here is the complete setup, in the order you would actually do it.

What you are building

        Internet
           │  :80 / :443
      ┌────▼─────┐   Let's Encrypt certificates, HTTP→HTTPS
      │  Traefik │
      └────┬─────┘
           │  docker network "traefik"
      ┌────▼─────┐   one process: API + embedded Vue app + job worker
      │   app    │
      └────┬─────┘
           │
      ┌────▼─────┐   not exposed to the internet
      │ postgres │
      └──────────┘
Enter fullscreen mode Exit fullscreen mode

Two containers you wrote nothing for, one that is your product. A 1 vCPU /
2 GB server runs this comfortably at the start.

Step 1: the server and DNS

Any provider works; for EU data residency, Hetzner, Scaleway and OVHcloud are
the usual answers. Point an A record (and AAAA if you have IPv6) at the
server, install Docker, and do the boring hardening: SSH keys only, automatic
security updates, a firewall that allows 22, 80 and 443.

DNS first, because Let's Encrypt validates over HTTP and will fail politely
until the record resolves.

Step 2: Traefik, once per host

docker network create traefik
ACME_EMAIL=you@example.com docker compose -f deploy/traefik/docker-compose.yml up -d
Enter fullscreen mode Exit fullscreen mode

Traefik now owns ports 80 and 443, redirects HTTP to HTTPS, and requests
certificates for any container that asks through labels. You do this once; a
second application on the same host joins the same network.

Step 3: configure the app

cp .env.example .env
Enter fullscreen mode Exit fullscreen mode

The variables that must be right in production:

Variable Value
APP_ENV production — turns on secure cookie flags
BASE_URL https://app.example.com, used in emails and OAuth callbacks
DOMAIN app.example.com, used by the Traefik labels
CSRF_KEY openssl rand -hex 32
POSTGRES_PASSWORD something random you never type again
DB_DRIVER postgres
MAILER resend (+ RESEND_API_KEY) or smtp
SUPER_ADMIN_EMAIL the address allowed into /admin
TRUSTED_PROXIES the docker network CIDR, so X-Forwarded-For is trusted only from Traefik

Three of them — APP_ENV, DB_DRIVER and TRUSTED_PROXIES — describe this
stack's topology and are already set by docker-compose.prod.yml, which wins
over .env. They are yours to fill in only if you deploy without that file.

make setup writes most of this interactively if you prefer answering
questions to reading a table.

Step 4: ship it

docker compose -f docker-compose.prod.yml up -d --build
Enter fullscreen mode Exit fullscreen mode

The multi-stage build compiles the Vue app, embeds it in the Go binary and
produces a distroless image running as a non-root user. Migrations run at
startup, so there is no separate migrate step to forget.

Two minutes later https://app.example.com answers with a valid certificate.

Step 5: the webhook and the mail domain

Two things are easy to postpone and painful to discover later.

Payments. Point the Stripe webhook endpoint at
https://app.example.com/api/billing/webhook and put the signing secret in
STRIPE_WEBHOOK_SECRET. Trigger a test event and check the log line.

Email deliverability. Publish SPF, add the DKIM records your provider
generates, and start DMARC at p=none with a reporting address. Send from a
subdomain (mail.example.com) so your root domain's reputation is insulated.
Mail without DKIM goes to spam at the two providers your customers use.

Step 6: backups you have restored

The part that separates a hobby deployment from a business:

# nightly, to somewhere that is not this server
docker compose -f docker-compose.prod.yml exec -T db \
  pg_dump -U govuekit govuekit | gzip > /backups/db-$(date +%F).sql.gz
tar czf /backups/uploads-$(date +%F).tar.gz /var/lib/docker/volumes/<project>_uploads
Enter fullscreen mode Exit fullscreen mode

Two volumes, not one. Blobs live outside the database, so a pg_dump alone
restores accounts with broken avatars and missing attachments. If you set
STORAGE=s3, the object store holds them instead and only the database is
yours to back up.

Then do the thing nobody does: restore into a scratch container and sign in.
An untested backup is a belief.

Step 7: updating

git pull
docker compose -f docker-compose.prod.yml up -d --build
Enter fullscreen mode Exit fullscreen mode

Migrations apply on boot. Rollback is the previous image tag, or the previous
binary if you deploy without Docker. Because the frontend is embedded, there
is no window where a new API meets an old bundle — they ship as one artifact.

Without Docker, if you prefer

make build              # bin/govuekit, frontend inside
scp bin/govuekit server:/opt/myapp/
Enter fullscreen mode Exit fullscreen mode

Then a systemd unit with Restart=always, an EnvironmentFile, and any
reverse proxy in front — Caddy gets certificates with two lines, nginx needs
certbot. The application does not care: it is one process listening on a port.

What to watch once it is live

  • Disk, because SQLite files, uploads and Postgres all live on it.
  • Certificate renewal, which Traefik handles but you should still be told about if it fails.
  • The dead-letter log line from the mail queue: it is the first sign that your provider's API key expired.
  • Error reports: set SENTRY_DSN, or plug your own reporter — every Error-level log record reaches it, including failures in background workers that have no request behind them.

That is the whole operational surface: one binary, one database, one proxy,
two backups. If you want the cost side of the argument, read
the rented stack bill.


Originally published on govuekit.dev. GoVueKit is a complete Go + Vue 3 SaaS codebase you buy once and own; the whole stack runs on your machine with one docker compose up: https://govuekit.dev/labs

Top comments (0)