Why self-host a Go API on a VPS
Unlike Java or Node, a Go application (with the Gin framework) compiles into a single static binary that needs neither an interpreter, nor a runtime, nor a shared library. You can literally drop the binary onto a VPS and run it. This lightness changes everything for self-hosting: a Gin API runs in 15 to 30 MB of RAM and starts in a few milliseconds. On a VPS, you make the most of this efficiency: an entry-level server is enough to serve a load that interpreted languages would require a much bigger machine for. You also avoid the complexity of bulky Docker images: a 10 MB scratch image is enough, and builds and pulls are near-instant.
Concrete benefits
- Single static binary: no runtime to install, deployment by a simple file copy
- Tiny memory footprint (15-30 MB) allowing you to saturate a small VPS with requests
scratchordistrolessDocker image of 10-15 MB: near-instant builds and pulls- Startup in milliseconds: redeployments and restarts with no perceptible interruption
- Native concurrency via goroutines: a single vCPU handles thousands of simultaneous connections
- Reduced attack surface: no interpreter or system packages to patch
Hardware and software prerequisites
This is where Go shines: a Gin API without a database fits on a VPS with 1 vCPU and 1 GB of RAM, and stays at ease. With co-located PostgreSQL, aim for 2 GB to be comfortable. You don't even need Go on the server if you compile in CI or by local cross-compilation (GOOS=linux GOARCH=amd64 go build). Install Docker and Docker Compose on the VPS. Point an api.mydomain.com domain at the VPS IP via an A record. 5 GB of disk is more than enough. On your development machine, Go 1.27+ is recommended — download from go.dev/dl.
Multi-stage Dockerfile with scratch image
The multi-stage Dockerfile is the key to a minimal final image. The first stage compiles the binary inside a full Go container; the second copies it into an empty scratch image — no shell, no OS, just your binary and the CA certificates for outbound HTTPS calls. Here is a complete Dockerfile for a Gin project:
FROM golang:1.27-alpine AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o server .
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]The CGO_ENABLED=0 flag forces a 100% static binary, essential for FROM scratch to work. The -ldflags="-s -w" flags strip debug tables and reduce binary size by about 30%. The CA certificates COPY is essential as soon as your API contacts external HTTPS services (Stripe, SendGrid, etc.). If you need a shell for debugging, replace FROM scratch with FROM gcr.io/distroless/static-debian12 — same footprint, but with a few basic tools.
Production Docker Compose with Nginx and Certbot
Create the file structure on the VPS
Connect via SSH and create the project directory:
mkdir -p /opt/myapp/{nginx/conf.d,certbot/www,certbot/conf} && cd /opt/myapp. This structure will hold the Nginx configuration and Certbot volumes.Write the docker-compose.yml file
Create
/opt/myapp/docker-compose.ymlwith three services:app(your Go image),nginx(reverse proxy) andcertbot(SSL renewal). Minimal example:services: app: image: monapp:latest restart: unless-stopped env_file: .env expose: - "8080" nginx: image: nginx:1.27-alpine restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./certbot/www:/var/www/certbot:ro - ./certbot/conf:/etc/letsencrypt:ro depends_on: - app certbot: image: certbot/certbot volumes: - ./certbot/www:/var/www/certbot - ./certbot/conf:/etc/letsencryptConfigure Nginx as a front-end proxy
Create
nginx/conf.d/app.conf. Start with an HTTP-only block to validate the ACME challenge, then add the HTTPS block after obtaining the certificate:server { listen 80; server_name api.mydomain.com; location /.well-known/acme-challenge/ { root /var/www/certbot; } location / { return 301 https://$host$request_uri; } } server { listen 443 ssl; server_name api.mydomain.com; ssl_certificate /etc/letsencrypt/live/api.mydomain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/api.mydomain.com/privkey.pem; location / { proxy_pass http://app:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }Obtain the Let's Encrypt certificate
Start Nginx alone first, then run Certbot in webroot mode:
docker compose up -d nginx && docker compose run --rm certbot certonly --webroot -w /var/www/certbot -d api.mydomain.com --email [email protected] --agree-tos. Once the certificate is issued, restart Nginx:docker compose restart nginx.Build and start the application
Build the Go image on the VPS (or transfer it via
docker save/loador a private registry):docker build -t monapp:latest .. Then start the full stack:docker compose up -d. Check logs withdocker compose logs -f app.Automate SSL renewal
Add a crontab on the VPS to automatically renew the certificate weekly:
(crontab -l 2>/dev/null; echo "0 3 * * 0 cd /opt/myapp && docker compose run --rm certbot renew && docker compose restart nginx") | crontab -. Certbot only actually renews if expiration is within 30 days.Configure Gin for production
In your code, switch Gin to release mode with
gin.SetMode(gin.ReleaseMode)or theGIN_MODE=releaseenvironment variable to disable verbose debug logs. Read the port and secrets from the environment (os.Getenv), never hard-coded in the code.
Live reload in development with air
In development, manually recompiling and restarting on each change is tedious. air (github.com/air-verse/air, currently v1.67.4) watches your source tree and restarts the binary on every .go file change. Installation: go install github.com/air-verse/air@latest. Simply run air at your project root — a default .air.toml config is generated automatically on first run. To customize watched directories or exclude vendor/:
[build]
cmd = "go build -o ./tmp/server ."
bin = "./tmp/server"
include_ext = ["go", "html", "tmpl"]
exclude_dir = ["vendor", "testdata"]air is for local development only: never install air in your production Docker image.
Environment variables and secrets
Never hard-code secrets (API keys, database passwords, JWT tokens) in source code. The standard Go practice is to read them from the environment with os.Getenv("DB_PASSWORD"). For local development, a .env file loaded by godotenv simplifies the workflow: go get github.com/joho/godotenv, then at startup:
import "github.com/joho/godotenv"
func main() {
_ = godotenv.Load() // loads .env if present, silent in prod
db := os.Getenv("DB_DSN")
port := os.Getenv("PORT")
if port == "" { port = "8080" }
// ...
}In production, pass variables via Docker Compose's env_file: .env directive (this file must never be committed — add .env to your .gitignore). For highly sensitive secrets (tokens, private keys), prefer Docker secrets or an external vault. The golden rule: a secret in a git log is a compromised secret.
Enable Go's pprof profiling behind a protected endpoint: import _ "net/http/pprof" exposed on an internal port accessible only via SSH tunnel (ssh -L 6060:localhost:6060 user@vps). You get CPU and memory flame graphs in real production without noticeable overhead. Also consider compiling with -ldflags="-s -w" to reduce the binary size by ~30% by stripping the debug tables.
Troubleshooting: common errors
Here are the five most frequent errors on a first Go VPS deployment, with their exact error messages and the fix to apply.
1. standard_init_linux.go:228: exec user process caused: no such file or directory — The binary was compiled with CGO enabled and the scratch image contains no libc. Fix: recompile with CGO_ENABLED=0 go build -o server ..
2. exec format error — The binary was compiled for a different architecture (e.g., macOS ARM → Linux AMD64). Fix: verify GOOS=linux GOARCH=amd64 before building, or build directly on the VPS.
3. bind: address already in use (port 8080) — Another process is already listening on that port. Check with ss -tlnp | grep 8080 and stop the conflicting process, or change the port in your .env.
4. x509: certificate signed by unknown authority or certificate has expired — Your scratch container lacks CA certificates (/etc/ssl/certs/ca-certificates.crt), or the Let's Encrypt certificate has expired. Fix: verify the COPY --from=build /etc/ssl/certs/ca-certificates.crt is present in your Dockerfile, and re-run Certbot to renew.
5. permission denied on binary startup — The binary is not executable. Fix: check permissions with ls -la server and apply chmod +x server if needed. In a Dockerfile, COPY --from=build normally preserves permissions, but an explicit RUN chmod +x /server guarantees the result.
Deploy Go (Gin) in one click from the ServOrbit Marketplace
The ServOrbit Marketplace offers a preconfigured Go (Gin) Stack template: Go from the official go.dev binary, Nginx and PostgreSQL 16 are installed automatically on your VPS from the order form. No SSH session for the base setup — you arrive directly at the step of compiling your project and uploading the binary. The marketplace page contains the exact specifications, use cases, and a first-connection guide.
Going further
You now have a complete Go VPS deployment under your belt. To go even further: configure Traefik v3 as your reverse proxy instead of Nginx — it handles routing, Let's Encrypt certificates and load balancing in a single container with declarative Docker label configuration (see our [Traefik v3 on VPS guide](/blog/traefik-v3-vps-reverse-proxy)). For zero-downtime production updates, check our article on [Docker Compose update best practices](/blog/docker-compose-mise-a-jour-best-practices-vps): rolling deployments, health checks and rollback strategies. If you prefer to delegate the infrastructure, our [Go (Gin) template in the Marketplace](/applications/developpement/golang) provisions the entire stack in minutes.