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 │
└──────────┘
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
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
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
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
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
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/
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.