Bot detection isn't perfect
But usually it's good enough to stop 80% of the worst actors with only 20% of the effort of doing it yourself.
Learn how to improve the security of your self-hosted Next.js applications. This guide covers Docker container image best practices, secret management, vulnerability mitigation, and more, so your Next.js projects are better protected from threats.
Self-hosting a Next.js application offers flexibility and control, but it also comes with security challenges. If you've decided to move beyond platforms like Vercel or Netlify, you're now responsible for everything from container image security to secret management.
Next.js has gained a reputation for being tricky to self-host, but recent updates have simplified containerization. That's great news for deployment, but it doesn't solve your security concerns.
Which container image should you choose, and how will you track security updates? What about the underlying platform? Host OS vulnerabilities? How do you handle environment variables and secrets? What are your firewall rules?
At Arcjet, we’re building an SDK to help developers protect their apps. We integrate with modern platforms like Vercel, but our rate limiting, bot detection, email validation and attack detection functionality works wherever you’re hosting your application (such as Coolify).
In this post, we'll tackle the most critical security considerations when self-hosting Next.js applications.
Before working on the infrastructure layer there are a few key things to get right in your application first. These include managing dependencies, data validation, avoiding code exposure, security headers, and centralization of security functionality.
These are all covered in our Next.js security checklist, so I won’t go through them again here.
Choosing the right container image is important for your Next.js application's security. While the official Next.js Docker example provides a starting point, there are optimizations to consider.
The official example uses Node 18 on Alpine Linux, a popular choice for its small size. However, Alpine's musl libc can lead to compatibility issues with certain libraries, especially if you're not developing and testing against musl locally.
A safer option is to use a standard Node image based on Debian, which is more widely compatible and less likely to cause unexpected issues. See this discussion for the pros/cons.
Node 18 is still in Long Term Support (LTS), but it's in maintenance-only mode. Consider upgrading to Node 20, the current Active LTS version, for active development and support. As per the docs:
Major Node.js versions enter Current release status for six months, which gives library authors time to add support for them. After six months, odd-numbered releases (9, 11, etc.) become unsupported, and even-numbered releases (10, 12, etc.) move to Active LTS status and are ready for general use. LTS release status is "long-term support", which typically guarantees that critical bugs will be fixed for a total of 30 months. Production applications should only use Active LTS or Maintenance LTS releases.
The Next.js Dockerfile example uses multi-stage builds to optimize caching and reduce the final image size.
The full version with all the build tools can be used for the earlier stages, but the final “runner” stage is where I would recommend using the “slim” image variant from the list of tags. The slim version "does not contain the common packages contained in the default tag and only contains the minimal packages needed to run node". Perfect for production.
node:20-bookworm
(or pin to a specific Node version like node:20.15-bookworm
)node:20-bookworm-slim
(or pin to a specific Node version like node:20.15-bookworm-slim
)(A full Dockerfile is provided at the end of this post)
The base OS is Debian Bookworm, which is the latest release. If you use node:lts
then you’ll also get Debian Bookworm. However, this will change over time so it’s good practice to pin specific versions. That way you don’t get sudden changes as new versions are released.
This combination offers a balance of compatibility, active support, and a reduced attack surface. Remember to regularly update the version numbers in your Dockerfile to stay current.
Another important security practice is to avoid running your application as the root user, even within a container.
If you launch Next.js as root then any code it executes will have root permissions. This allows them to install other software, run other processes, and access all files within the container. This makes it easier to exploit a container escape vulnerability, read out your environment variables, or access other tools within the container.
If an attacker exploits a vulnerability, running as a non-root user limits the potential damage.
The official example demonstrates creating a dedicated nextjs
user and nodejs
group. Follow this practice in your Dockerfile, ensuring files are owned by this user and the main process runs as nextjs
Copy over the output traces when building Next.js in standalone mode and chown
them to the nextjs:nodejs
user and group:
Finally, you need to set the USER
directive before CMD
A full Dockerfile is provided below.
Next.js doesn't natively offer robust secret management, but there are several strategies to ensure your sensitive information remains protected within a containerized environment.
As recommended in our Next.js security checklist, avoid storing secrets directly as environment variables. Instead, leverage a dedicated secret manager tool. This provides centralized control, audit trails, and easier rotation of credentials.
One option is to use dotenvx which allows you to encrypt secrets inside the .env
file. An alternative is to use references stored in a tool like 1Password. By referencing secrets in a .env
file and using a CLI to fetch them at launch, you avoid exposing secrets in your container's environment.
Alternatives include Hashicorp Vault Secrets, AWS Secrets Manager, Doppler, and Infisical. We’re using 1Password because we also use it for all our other company secrets.
Here I created a .env.production
file with the secret names pointing to the 1Password reference:
The 1Password CLI requires that a service account token is set as OP_SERVICE_ACCOUNT_TOKEN
which has limited access just to read values from a specific 1Password vault.
We bring the op CLI into the container with this COPY directive:
COPY --chown=nextjs:nodejs --from=1password/op:2 /usr/local/bin/op /usr/local/bin/op
Then the CMD
to start the Next.js server is executed through the 1Password CLI run
command (docs):
CMD [
"/usr/local/bin/op", "run", "--env-file=/app/.env.production",
"--", "node", "/app/server.js"
]
This eliminates all secrets from the environment variables, except for the service account token. Although this token could be used to access the secrets from 1Password, the token is scoped to a specific vault and you gain benefits such as:
This is a major improvement over plain environment variables which have no logging, can’t be as easily rotated, and are not tracked anywhere.
This Dockerfile leverages Node 20 on Debian Bookworm to install project dependencies and build the Next.js application. It assumes you have set output: "standalone"
in your next.config.js
then copies the standalone output into a minimal runtime environment, executing as a non-root user for enhanced security. Secrets are managed externally using 1Password, referenced within a .env.production
file.
You can build and run with the following commands:
docker build -t nextjstest1:latest .
docker run -it --rm --name nextjstest1 -p 3000:3000 -e OP_SERVICE_ACCOUNT_TOKEN=... nextjstest1:latest
If you get an error like this then check that your 1Password service token is correct:
[ERROR] error initializing client: Validation: (failed to
session.DecodeSACredentials), Server: (failed to
DecodeSACredentials), failed to parseToken, format is invalid
While convenient for debugging, including a shell in your production Next.js container introduces unnecessary risk. A shell provides an additional attack vector that malicious actors could potentially exploit.
Why Remove the Shell?
Alternative Approaches
Trade-offs
Removing the shell makes debugging within the production container more challenging. Further, using a Distroless container image is not compatible with the 1Password CLI to load secrets at runtime. The Distroless image removes some dependencies needed by the CLI, preventing it from running.
This means you will either need to load secrets into environment variables or integrate a secrets manager SDK into your code. If you’re on AWS and using Kubernetes then you can use AWS Secrets Manager with EKS.
Distroless images contain no build tools by design so we still use the Node 20 Bookworm image as our base, but Distroless for the runner:
Self-hosting Next.js gives you more flexibility and control, but it demands a proactive approach to security. By carefully selecting container images, managing secrets effectively, and minimizing the attack surface, you can ensure your Next.js applications are both performant and resilient against potential threats.
Remember, security is an ongoing process. Stay informed about the latest vulnerabilities, regularly update your dependencies, and adopt a defense-in-depth strategy to protect your Next.js projects.
But usually it's good enough to stop 80% of the worst actors with only 20% of the effort of doing it yourself.
How to protect GraphQL backends using Arcjet. Implementing rate limiting and bot protection for Yoga + Next.js.
Fly automatically builds and deploys your containerized app globally, but you still need to handle the application security.
Get the full posts by email every week.