One pipeline, many sites: our Docker and Woodpecker setup

How a small studio deploys several independent apps from one CI server and one modest droplet, and the scheduling gotcha that silently stopped a site from deploying.

Photo by Bernd 📷 Dittrich on Unsplash

We run several live products: web apps, their APIs, marketing sites and this studio site. They all deploy the same way, from one Woodpecker CI server, onto one small droplet. Here’s the setup, and the lessons that took the longest to learn.

The shape of every deploy

Every repository has the same three files at its root:

  • A multi-stage Dockerfile. The first stage builds; the last stage is just what runs. This site’s final image is nginx serving static files. A Next.js app’s final image is a standalone Node server.
  • A Woodpecker pipeline. On every push to the main branch, it builds the image, pushes it to a private registry next to the server, and runs a deploy script.
  • A deploy script with a small config file, which mostly sets the domain and a port. The script pulls the new image, restarts the container, and makes sure the host’s nginx has a site for the domain.

The host nginx terminates TLS for every domain and proxies to each app’s container on its own port. Adding a site means picking a free port and writing a config file, not designing a new deployment.

Secrets live in CI and on the server, never in the repo

Values the browser needs, such as a public API URL, are baked in at build time, so they’re Woodpecker secrets passed in as build arguments. Values only the server needs, such as database credentials, live in an .env file on the droplet that CI never touches. The repos hold only variable names.

When one codebase deploys as several products, each product gets its own set of build secrets and its own image names. Then the pipelines can’t accidentally build one site with another’s settings.

When the server got too small to build on

Running production apps and building them on the same 2 GB server works until it doesn’t. A Next.js production build is memory-hungry, and one day it pushed the droplet into swap so badly that docker ps stopped responding.

The fix was a second Woodpecker agent on a workstation with plenty of memory. Heavy builds run there and push their images to the droplet’s registry. The droplet’s own agent only runs the deploy step. The production server never compiles anything anymore.

The trade-off is that builds only run while that workstation is on. For a small studio that’s acceptable, and it’s documented in the repo so nobody is surprised.

The gotcha: labels pick an agent per workflow, not per step

With two agents, you tell Woodpecker where work should run using labels. Our first attempt labeled individual steps: build steps on the workstation, deploy step on the droplet. It silently did nothing, because Woodpecker assigns a whole workflow to one agent and ignores step-level labels.

The real structure is two workflow files: a build workflow labeled for the workstation, and a deploy workflow labeled for the droplet that waits for the build to finish.

The same rule caught us again later, on a repo whose pipeline had no label at all. Woodpecker was free to schedule it on either agent. When it landed on the workstation, the build “succeeded”, but the image never reached the server, so the live site quietly stopped updating. Every workflow now names its agent explicitly. An unlabeled workflow is a coin toss.

Small rules that prevent big afternoons

  • One build at a time per agent. Two big Docker builds on the same machine at once failed with a timeout. Related workflows now run one after the other.
  • Migrations ship as their own image, so the deploy runs them as a separate step instead of hiding them inside app startup.
  • Optional steps are marked optional. Importing blog drafts during a deploy is nice to have, so a failure there doesn’t block a release.
  • Verify on the live site, not in CI. A green pipeline means the steps ran. Only fetching the real URL tells you the new version is actually serving.

None of this is sophisticated, and that’s the point. Every site deploys the same boring way, so each new site costs us an afternoon.

Planning an app?

Start a project