1. Introduction: running a startup application without a platform team
Early-stage products need to behave like production systems long before they have production budgets. Users expect HTTPS, deployments that don't take the site down, and data that survives a bad day. A founder or small engineering team also has to keep the monthly infrastructure bill low.
This article documents how I turned a single Ubuntu VPS into a containerized deployment platform for a Next.js web application. It covers the path from an empty server to a public application with automated deployments, database backups, and monitoring. The guiding principle was to reduce operational cost without giving up the foundations: security, deployment reliability, data protection, and observability.
A single VPS is cheaper than a multi-server platform, but it is also a single point of failure, and every service on it becomes my operational responsibility. This is a case study of a deliberate trade-off at a specific scale, not a blueprint for high availability.
2. Requirements and architectural decisions
Before choosing tools, I wrote down what the system had to do:
- Serve the application publicly over HTTPS.
- Deploy new versions from Git without taking the site offline.
- Keep the database off the public internet and persist its data across container restarts.
- Store application images in a registry I control.
- Keep secrets out of plain-text repositories.
- Keep database backups somewhere other than the VPS.
- Give me visibility into CPU, memory, disk, network, and container activity.
- Keep the administrative surfaces private.
A single VPS satisfies all of these at the lowest direct cost. I accepted the consequences: services share CPU, memory, and disk, and a host failure takes everything down at once. Backups and a rehearsed recovery path therefore matter more than they would on a multi-node platform.
Each component below was chosen for its specific responsibility:
- Docker and Docker Compose run the supporting services.
- Nginx Proxy Manager (NPM) is the public entry point and handles TLS.
- Kamal and Kamal Proxy manage the application container lifecycle and traffic switching.
- PostgreSQL stores application data.
- Zot is the self-hosted OCI image registry.
- GitHub Actions runs CI/CD.
- dotenvx encrypts environment files.
- Cloudflare R2 stores off-server backups.
- Cron schedules the backup job.
- Netdata provides monitoring.
3. Architecture overview
Two paths matter: how a request reaches the application, and how a new version reaches the server.
The request path
Nginx Proxy Manager and Kamal Proxy are not redundant load balancers; they solve different problems.
- NPM is the public-facing reverse proxy. It terminates TLS and routes hostnames to internal services. The same VPS can serve the application and other internal services behind it.
- Kamal Proxy is the deployment proxy. It knows which application container is currently healthy. During a deployment it starts the new container, waits for it to pass health checks, and then switches traffic to it.
NPM does not need to know that a deployment happened. It keeps pointing at Kamal Proxy, and Kamal Proxy decides which container receives the request.
The deployment path
main → deploy branch → GitHub Actions → Docker Buildx → Zot registry → SSH → Kamal → Kamal Proxy
Building an image, storing an image, and deploying an image are separate responsibilities. GitHub Actions builds and publishes the image to Zot. Kamal pulls that image on the server and manages the container lifecycle.
Docker networks and what they are for
Network membership follows actual communication requirements:
| Network | Members | Reason |
|---|---|---|
deploy-proxy |
Nginx Proxy Manager, Zot, Netdata | The services NPM needs to reach by container name. |
kamal |
Kamal-managed application containers, PostgreSQL | Lets the application reach the database by container name without exposing the database publicly. |
Both networks are created ahead of time and declared as external in the Compose files, so several independent stacks
can share them. The Zot and PostgreSQL Compose files also define their own stack-local bridge networks
(zot-network and barknbowl-network).
Docker networks limit which containers can resolve and reach each other by default, but they are not a complete security boundary. Any container attached to a network can talk to the other members, and published host ports are a separate matter. I treat networks as one control among several.
4. Preparing the VPS
The pipeline only works if the server is prepared first. The order matters because each step is a prerequisite for the next.
- Create a dedicated deployment user. CI/CD connects as this user (the tunnel example below uses
deploy) rather than as root. - Configure SSH key authentication. The user's
~/.sshdirectory andauthorized_keysfile need strict ownership and permissions, or the SSH daemon will ignore them. The matching private key is stored as a GitHub Actions secret, never in the repository. - Restrict administrative access. Once key-based login works, password authentication and direct root login can be disabled. Verify this in your own SSH daemon configuration instead of assuming it.
- Install Docker and Docker Compose. Kamal and the supporting services all depend on the Docker engine.
- Create the shared networks.
deploy-proxyandkamalmust exist before any Compose stack that declares them as external. - Prepare persistent storage for PostgreSQL and the Zot data directory.
- Configure Zot authentication with an
htpasswdfile, and prepare R2 credentials for the backup job.
Docker access is not an unprivileged role. A deployment user is better than root, but if it belongs to the
dockergroup, it can start containers that mount the host filesystem. That is effectively root-level power. The deployment SSH key therefore deserves the same protection as a root credential.
One detail in my Kamal configuration deserves attention: the SSH user falls back to root if
SERVER_USER is not set. That default is convenient during setup but works against the dedicated-user
principle. If you copy the pattern, remove the fallback so a missing variable fails loudly instead of deploying as
root.
5. Building the runtime
PostgreSQL
PostgreSQL 16 runs in a container managed with Docker Compose. It listens on port 5432 inside Docker,
but the Compose file uses expose rather than ports, so nothing is published on the host.
The application connects through the kamal network by container name.
The application and the database are both on the same Docker network, so the database never needs a public address.
The service excerpt below shows the relevant parts of the Compose file. The commented-out ports mapping
is a deliberate reminder that host access is a temporary diagnostic choice, not a default.
services:
db:
image: postgres:16-alpine
restart: unless-stopped
expose:
- "5432"
# ports:
# - "5433:5432" # temporary diagnostics only
volumes:
- barknbowl_pgdata:/var/lib/postgresql/data
networks:
- barknbowl-network
- kamal
Credentials come from environment variables rather than being written into the Compose file. The named volume keeps data across container restarts and re-creation. It does not protect against disk failure or a deleted volume, which is why backups are a separate concern.
Zot registry
A private registry gives the pipeline a place to publish images that I control, without relying on an external service for the deployment path. Zot is a lightweight OCI registry that fits comfortably on a small VPS.
The Zot container is attached to deploy-proxy and has its own zot-network. Its Compose file
uses expose: 5000 and keeps the ports mapping commented out, so the Compose file by itself
does not publish the registry on the host. Authentication uses an htpasswd file mounted read-only. Read
and write timeouts are raised in the Zot configuration to tolerate large image pushes.
The current setup accesses the registry over plain HTTP using an IP-based address, so both sides must be told to accept that:
- In GitHub Actions, a BuildKit configuration marks the registry as
http = trueandinsecure = true, and the Docker daemon on the runner lists it underinsecure-registries. - The Docker daemon on the VPS needs the same treatment so Kamal can pull the image. This is a server-side step to verify in your own environment.
This choice simplifies the setup because there is no certificate to manage for the registry, but it is not the posture I would want long term. HTTP is not encrypted, and registry authentication does not protect credentials in transit over HTTP. Before relying on this setup, confirm exactly how the registry is reachable from the runner and who can reach that address. The Compose file alone does not establish that. The natural next step is putting the registry behind TLS, for example through NPM with a proper hostname.
I also use latest for the Zot image tag in Compose. Pinning a specific version would make registry
upgrades deliberate rather than accidental.
Nginx Proxy Manager and Kamal Proxy together
NPM is the only component meant to face the public internet for web traffic. Its admin UI is not exposed. I reach it through an SSH tunnel:
ssh -L 8081:127.0.0.1:81 deploy@<SERVER_HOST>
The admin interface is then available at http://127.0.0.1:8081 on my own machine. The distinction is
important: the application is meant to be public, while the administrative control panel is a high-value target that
controls routing and certificates for everything behind it.
The Kamal proxy settings in my configuration are:
proxy:
ssl: false
app_port: 3001
forward_headers: true
healthcheck:
path: /api/health
interval: 2
timeout: 3
run:
http_port: 8080
https_port: 8443
publish: true
bind_ips:
- 127.0.0.1
ssl: false is intentional, because TLS is terminated at NPM. The application listens on port
3001 inside its container, and forward_headers passes the original request headers through
the proxy chain. Zero-downtime behavior depends on the /api/health endpoint reporting readiness
correctly: if the endpoint returns success before the application can actually serve traffic, the switch can happen
too early.
Verify the upstream path. The intended NPM upstream is
http://kamal-proxy:80. Therunblock above, however, publishes Kamal Proxy's HTTP listener on127.0.0.1:8080on the host. A container-name upstream such askamal-proxyrequires that NPM and Kamal Proxy share a Docker network and that NPM targets the port Kamal Proxy listens on there. A host port bound to loopback is not reachable from another container. Whichever route you choose, confirm it withdocker network inspectand a test request, and make the Kamal configuration and the NPM proxy host agree.
6. Automating deployment
The release flow
Day-to-day development is integrated into main. A push to main does not deploy anything.
When a release is ready, I merge main into deploy, and the workflow is triggered only by
pushes to deploy. This makes production releases a deliberate step.
- Changes land on
main. - A release-ready state is merged from
mainintodeploy. - GitHub Actions runs the workflow for the
deploybranch. - The workflow decrypts the production environment file with dotenvx.
- Docker Buildx builds the application image.
- The image is tagged with the Git commit SHA and
latestand pushed to Zot. - The workflow connects to the VPS over SSH.
- Kamal deploys the exact image version identified by the commit SHA.
- Kamal Proxy switches traffic to the new container once it is healthy.
- NPM keeps routing public requests to Kamal Proxy, so nothing changes at the edge.
The workflow also prepares the runner before building: it writes a BuildKit configuration and a Docker daemon setting for the HTTP registry, sets up Buildx, and logs in to Zot. It uses the GitHub Actions cache for build layers.
The key point is the handoff between build and deploy. The build step pushes the image under a SHA tag, and the
deployment step then runs Kamal with --skip-push so it does not rebuild:
kamal deploy --skip-push --version=${{ github.sha }}
Because the version is pinned to the commit SHA, Kamal deploys exactly what the pipeline just built rather than
whatever latest happens to point to. I have not documented rollback behavior here because I have not
verified it in this setup.
dotenvx, secrets, and Kamal
These five concepts are easy to conflate, so I keep them separate:
- Encrypted environment file.
.env.productionis encrypted with dotenvx and can live in version control. - dotenvx private key. This is the only thing that can decrypt it, so it never goes into the repository.
- GitHub Actions Secrets. The private key is stored here and exposed to the workflow only for the steps that need it.
- Kamal deployment-time secret handling. Kamal reads secrets from
.kamal/secretsand passes the variables named underenv.secretto the container. - Application runtime environment. The running container receives those variables, along with a
few non-sensitive values (
NODE_ENV,PORT,HOSTNAME) that Kamal passes in clear form.
The decryption step in the workflow is:
- name: Decrypt production environment file
env:
DOTENV_PRIVATE_KEY_PRODUCTION: ${{ secrets.DOTENV_PRIVATE_KEY_PRODUCTION }}
run: |
npx --yes @dotenvx/dotenvx decrypt -f .env.production
cat .env.production >> .kamal/secrets
Encryption protects the file at rest in the repository, but it does not protect the values once they are decrypted.
During the workflow the plain values exist on the runner, so the runner, the workflow definition, and anyone who can
modify it become part of the trust boundary. Review who can edit workflow files and who can push to
deploy.
The env.secret list in the Kamal configuration covers several categories of values: the database
connection string, authentication settings, an administrative account, payment provider credentials, analytics
credentials, and an email service key. I deliberately do not list them individually in this article.
Build-time versus runtime variables in Next.js
Next.js does not treat all variables the same way. Variables used during next build, including values
inlined into client-side code, must be available at build time. Server-side variables can be read at runtime from
the container environment. Passing everything only at runtime is therefore not always correct.
My workflow handles this by passing the production dotenvx private key into the Docker build as a build argument, so
the build can decrypt what it needs. This is convenient but sensitive, because build arguments can persist in image
metadata or layers depending on how the Dockerfile uses them. I have not reproduced the Dockerfile here. If you use
this pattern, confirm that the key does not survive into the final image (for example with
docker history), and consider a multi-stage build or a BuildKit secret mount instead.
GitHub Actions Secrets
The repository defines these secrets, which fall into four groups:
| Category | Secrets | Purpose |
|---|---|---|
| Application configuration | APP_NEXTAUTH_URL |
Application-level URL configuration. |
| dotenvx decryption keys | DOTENV_PRIVATE_KEY_DEVELOPMENT, DOTENV_PRIVATE_KEY_PRODUCTION |
Decrypt the environment file for the matching environment. |
| Registry connection and authentication | REGISTRY_HOST, REGISTRY_USERNAME, REGISTRY_PASSWORD |
Reach and authenticate to Zot. |
| Server connection and deployment | SERVER_HOST, SERVER_USER, SERVER_SSH_KEY,
DEPLOY_SSH_KEY, SERVER_DEPLOY_PATH |
Connect to the VPS and locate the deployment target. |
In the workflow file I reviewed, the production decryption key, the registry secrets, SERVER_HOST,
SERVER_USER, and SERVER_SSH_KEY are referenced. The SSH key is loaded into an SSH agent
before Kamal runs. The other secrets (APP_NEXTAUTH_URL, DEPLOY_SSH_KEY,
SERVER_DEPLOY_PATH, and the development decryption key) do not appear in that file, so they may belong
to other workflows or be left over from earlier iterations. The roles of DEPLOY_SSH_KEY and
SERVER_SSH_KEY in particular should be checked against the workflows that use them before being
documented or rotated, and unused secrets are worth removing.
The registry password reaches Kamal through the variable name KAMAL_REGISTRY_PASSWORD set in the deploy
step, which keeps the secret out of the Kamal configuration file itself.
7. Protecting application data
Persistent storage and backups solve different problems. The PostgreSQL volume protects data from container restarts. It does not protect against disk failure, accidental deletion, or loss of the VPS. Backups must therefore leave the server.
The backup lifecycle
PostgreSQL → database dump → validation → upload to R2 → retention and recovery
A backup script runs on the VPS and does the following:
- Loads its configuration from a
.envfile next to the script and checks that the database container is running. - Runs
pg_dumpinside the container in PostgreSQL's custom compressed format and writes a timestamped.dumpfile to a local backup directory. - Checks that the dump file exists and is not empty.
- Uploads the file to a Cloudflare R2 bucket using the S3-compatible API through the
amazon/aws-cliimage. - Deletes local dump files older than a configured number of days, and appends each step to a log file.
Cron runs the script every day at 3:00 AM. The entry below is an example with a placeholder path:
0 3 * * * /path/to/sync-backup.sh
Cron uses the server's timezone, while the script logs in UTC, so confirm which one you actually get. The script also
sets an explicit PATH, since cron runs with a minimal environment.
The R2 credentials and bucket settings live in the script's .env file. That file should be readable only
by the account running the job, and R2 access keys should be scoped as narrowly as possible. R2 can be a low-cost
option for small backup volumes and may have a free tier, but pricing changes, so check current terms instead of
assuming the backups will always cost nothing.
Restore considerations
A backup is only useful if it can be restored. I wrote a companion script with three actions: list the backups in R2,
download a specific one, and restore from a local file. Restoring uses pg_restore with
--clean --if-exists and asks for explicit confirmation first, because it overwrites existing data in
the target database.
What exists today, and what does not:
- The validation in the backup script only checks that the dump is non-empty. It does not prove the dump is restorable.
- The local retention setting removes only local files. I have not configured retention or lifecycle rules for the R2 bucket in the script.
- I have not set up automated restore testing.
- The script does not encrypt dumps itself, so protection of the data in R2 relies on the bucket's access controls and the provider.
The practical consequence is that a restore drill into a scratch database is part of operating this system, not an optional extra. I would run one after any change to the backup process, and I would not consider the setup finished until that drill has been done and written down.
8. Monitoring and operational visibility
On a single VPS, resource contention is the main way the services can hurt each other. A build, a large query, or a runaway container can starve the others. Netdata gives me visibility into CPU, memory, disk usage, network activity, and container behavior on one machine.
It runs as a Docker container attached to deploy-proxy and exposes its dashboard port,
19999, only inside Docker. To observe the host, it shares the host PID namespace, mounts parts of the
host filesystem read-only, mounts the Docker socket read-only, and adds extra Linux capabilities. Those privileges
are what make the monitoring useful, and they also make the container a sensitive one. Read access to the Docker
socket reveals a lot about the host, which is another reason the dashboard must not be public.
Netdata and the NPM admin UI are different services on different ports. The earlier tunnel (8081 to port
81) is for NPM. Because the Netdata Compose file publishes nothing on the host, reaching its dashboard
from outside requires a deliberate private route. Options include publishing the port bound only to
127.0.0.1 and tunnelling to it, or routing through NPM with strict access restrictions. An example of
the first:
ssh -L 19999:127.0.0.1:19999 deploy@<SERVER_HOST>
This example only works if the container's port is bound to the host loopback interface. With the current
expose setting it would not, so choose and verify one route before relying on it.
Monitoring is not alerting, log aggregation, or high availability. Unless alert notifications are configured and tested, Netdata is a place I look when something seems wrong, not a system that wakes me up. Configuring notifications is a sensible next step.
9. Security and cost trade-offs
What the architecture protects, and what it does not
I treat the following as separate controls that complement each other:
- SSH key authentication for server access, with a dedicated deployment user.
- Encrypted environment files with private keys held in GitHub Actions Secrets.
- Docker network membership to limit which containers can talk to each other, with the database not published to the host.
- Registry authentication through
htpasswd. - Private admin interfaces reached through SSH tunnels.
- Off-server backups in R2.
The gaps are equally important to state:
- The registry uses HTTP, so credentials and images are not encrypted in transit.
- A Docker-group deployment user has broad power over the host.
- Decrypted secrets exist on the CI runner during a deployment.
- This setup has not been penetration-tested and I do not claim it is fully hardened.
- Zero-downtime deployment is not high availability. The server itself is still a single point of failure.
Where the cost savings come from
The savings come from reusing one VPS for several services and adding external services only where they give clear value:
- Self-hosted PostgreSQL avoids a managed database subscription at the start.
- Self-hosted Zot avoids depending on a paid private registry. I make no claim about other providers' pricing or limits here.
- Netdata is open source, so there is no separate monitoring subscription for the core setup.
- GitHub Actions handles CI/CD without a dedicated CI server.
- Cloudflare R2 provides off-server backup storage whose cost depends on current usage and pricing.
- Docker networks and SSH tunnels avoid exposing internal services and the need for extra infrastructure.
I do not give a monthly total, and it would be wrong to say everything here is free. The more important point is that lower direct cost moves work onto the engineering team: applying updates, watching disk usage, rotating credentials, testing restores, and recovering the server if it fails. For a small team this is often a fair exchange. It is still a trade, and the engineering time belongs in the calculation.
The VPS also has hard limits: shared CPU and memory, finite disk, and no failover. I would consider moving the database to separate or managed infrastructure when its load starts affecting the application, when recovery-time requirements tighten, or when I no longer want to own backups and upgrades. Moving the application to additional servers would follow actual capacity or availability needs, not a wish for a more sophisticated architecture.
10. Lessons learned and next steps
The most useful lessons were about boundaries rather than tools:
- Keep responsibilities distinct. NPM handles public ingress, Kamal Proxy handles deployment switching, and GitHub Actions builds images. Mixing these roles makes failures hard to diagnose.
- Verify the network path, not just the configuration. The proxy upstream, registry reachability, and tunnel targets each depend on network details that a configuration file alone does not prove.
- Treat build-time secrets carefully. A variable that Next.js needs at build time is a different problem from one the container reads at runtime.
- Backups need restore rehearsal. A successful upload tells me the job ran, not that the data can be recovered.
Sensible next steps, in rough priority order:
- Put the registry behind TLS and stop relying on insecure HTTP access.
- Run and document a restore drill, then schedule it periodically.
- Configure retention for the R2 bucket and consider encrypting dumps before upload.
- Configure Netdata alert notifications.
- Pin image versions for Zot and other supporting services, and remove the SSH
rootfallback. - Review the Dockerfile to confirm the dotenvx key does not persist in image layers, and prune unused GitHub secrets.
11. Conclusion
A single VPS can support a disciplined production workflow: public HTTPS routing, branch-driven releases, versioned images in a private registry, encrypted environment files, scheduled off-server backups, and host-level monitoring. The value of this architecture is that each component has a clear job and the system stays understandable for a small team.
Its limits are equally clear. It is a single point of failure, deployments are zero-downtime but not highly available, and the registry still needs transport encryption. Restore testing and alerting are unfinished work. Those gaps are acceptable at the current scale only as long as they stay visible and get closed deliberately.